Compare commits

...

133 Commits

Author SHA1 Message Date
Brett Jephson 1ae09b1ec5 Merge branch 'main' into brett/fa-icons-rendering 2026-05-05 09:49:03 +01:00
conico974 f71ab8426c Bust page document data cache (#4234) 2026-05-04 16:15:27 +02:00
conico974 a3d1558f99 Disable regional cache for debugging purposes (#4233) 2026-05-04 14:03:29 +02:00
conico974 ca53869e2d Reduce cache TTL for regional cache and log cache debug (#4232) 2026-05-04 13:32:36 +02:00
conico974 33598e665b Add logging for cache retrieval in GitbookIncrementalCache (#4231) 2026-05-04 11:47:47 +02:00
Samy Pessé 9045ba5248 Remove progressive rollout for markdown stable links (#4230) 2026-05-01 21:56:28 +02:00
Brett Jephson 6cd9f2aaaa update 2026-04-30 20:38:57 +01:00
Brett Jephson 6d4a1653a2 use sprites to parse symbols 2026-04-30 18:52:05 +01:00
Brett Jephson 9ce2759e1b move spritesheet up 2026-04-30 18:05:38 +01:00
Brett Jephson 5663064b46 Speed up icon rendering 2026-04-30 17:32:51 +01:00
Brett Jephson 6246b106af formatting 2026-04-30 15:11:15 +01:00
Brett Jephson 9ad85cb30c tidy 2026-04-30 15:07:46 +01:00
Brett Jephson 25bcff6eff Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 15:02:00 +01:00
Brett Jephson f232b8fff5 e2e tests 2026-04-30 15:01:32 +01:00
Brett Jephson e7f9bc2565 use route to transform svg to local symbol 2026-04-30 14:57:34 +01:00
Rodrigo Castro 7061fc2223 Fix hidden pages in sitemap exports (#4216) 2026-04-30 14:04:37 +01:00
Brett Jephson 21053f56fb Update method of loading symbols 2026-04-30 13:47:16 +01:00
Brett Jephson 2746ffc000 test fix 2026-04-30 12:01:53 +01:00
Brett Jephson 568ee3a660 tidy and update icon loading in test 2026-04-30 11:31:00 +01:00
Brett Jephson 420bf4a6f6 Merge branch 'main' into brett/fa-icons-rendering 2026-04-30 10:52:51 +01:00
Brett Jephson 72cffdd14b tidy 2026-04-30 10:45:25 +01:00
Samy Pessé 0c9e3952b7 Increase rollout of markdown stable links to 50% (#4228) 2026-04-30 11:37:37 +02:00
Brett Jephson 07be0e224c use shared supported style info 2026-04-30 10:16:35 +01:00
Brett Jephson c765394914 tidy 2026-04-30 10:16:35 +01:00
Brett Jephson ab96ee806b changeset 2026-04-30 10:16:35 +01:00
Brett Jephson 7cc6090e03 load icons as symbols 2026-04-30 10:16:35 +01:00
Samy Pessé 4b78672135 Fix links to other site-spaces / sections in llms.txt (#4222) 2026-04-30 08:43:32 +02:00
conico974 10289e4881 Revert search worker changes and add error handling (#4226)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:59:05 +02:00
conico974 8b6a6df1d6 Move search into a web worker (#4223)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:42:44 +02:00
conico974 83df84ac28 Improve markdown serving logic (#4224)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:28:45 +02:00
Greg Bergé 5e5075a137 Make Mermaid controls shown only on hover (#4221) 2026-04-29 17:13:31 +02:00
Greg Bergé 90c1f68e19 Speed up Mermaid rendering (#4219) 2026-04-29 15:40:24 +02:00
Zeno Kapitein 0e10124626 Adapt max logo height (#4218) 2026-04-28 14:59:45 +02:00
Zeno Kapitein 01e1c2ae6a Add space between ask button and content (#4217) 2026-04-28 14:59:14 +02:00
Zeno Kapitein 65558cfbb1 Redesign search results (#4198)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2026-04-28 14:49:46 +02:00
conico974 10d894ce19 Integrate local search functionality (#4078)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:14:47 +02:00
Samy Pessé d1a2958212 Pass visitor session to streamAskQuestion (#4211) 2026-04-24 21:07:46 +02:00
Viktor Renkema 894b6adc4c Support marks colors for text and background (#4212) 2026-04-24 17:23:10 +02:00
Nolann B. b3b819f8e5 Disable MCP server when MCP page action is off (#4210) 2026-04-23 18:01:43 +02:00
spastorelli 2b98af0cc0 Fix not found pages showing duplicate trademark badges (#4209) 2026-04-23 16:31:40 +01:00
Samy Pessé 54a56ed772 Remove rollout for agent ask (#4208) 2026-04-23 14:48:06 +02:00
Samy Pessé 071627d4f0 Improve the footer for agent to query the docs (#4206) 2026-04-22 22:56:28 +02:00
Samy Pessé 7595706b92 Allow AI agents to ask questions using ?ask= (#4205) 2026-04-22 08:43:57 +02:00
conico974 be080248b1 Block search indexation in deployment configuration (#4203) 2026-04-20 16:22:34 +02:00
Samy Pessé d3d51507e5 Add "Content-Signal" to robots.txt (#4202) 2026-04-20 14:14:59 +02:00
Addison a17ab65a6d Show variants in fully hidden sections (#4201) 2026-04-20 16:41:24 +09:00
Zeno Kapitein e3aca9e4f6 Fix embeddable search links (#4196) 2026-04-18 00:43:07 +02:00
spastorelli 045f603849 Add ~gitbook/auth/logout endpoint in GBO (#4195) 2026-04-16 22:40:56 +02:00
Greg Bergé b2854500a1 Allow to scroll to top of the page (#4194) 2026-04-16 16:20:50 +02:00
Samy Pessé ead88591c2 Version Packages (#4193)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-16 14:19:37 +02:00
Peter White aa1fc246e2 Add support for sticky first column in tables (#4188) 2026-04-16 10:33:15 +00:00
Samy Pessé e792cffeb3 Version Packages (#4178)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-04-15 19:07:34 +02:00
Zeno Kapitein 119e3bc369 Refine Assistant phase UI (#4187) 2026-04-15 18:29:58 +02:00
Greg Bergé 823e7d5c4b Fix tags appearing on top of page even if disabled (#4192) 2026-04-15 18:26:06 +02:00
Claire Chabas f5f659e7fc Display updates blocks headings in page outline (#4167) 2026-04-15 16:20:51 +02:00
Brett Jephson 723e579958 Fix: whitespace of Expandable in Column (#4190) 2026-04-15 14:07:20 +01:00
conico974 f27901c453 Update deployment ID logic for environment-specific prefixes (#4191) 2026-04-15 14:52:44 +02:00
Zeno Kapitein 789c2fab60 Adjust empty TOC logic and layout (#4189) 2026-04-15 14:04:13 +02:00
conico974 3fa727c80b Migrate to Next 16 (#4162) 2026-04-15 13:20:25 +02:00
Zeno Kapitein 88c38fa505 Add Search tab to Docs Embed, refactor search into an embeddable frame (#4185) 2026-04-15 12:09:59 +02:00
Claire Chabas bda9fe2d76 Make ToC groups collapsible and other styles fixes/improvements (#4177) 2026-04-14 19:41:51 +02:00
Nolann B. 2bdade3b57 Fix HTML lang attribute not reflecting language variants (#4186) 2026-04-14 13:14:39 +02:00
Zeno Kapitein 8d2a95b168 Docs Embed: Better support light/dark mode overrides (#4181) 2026-04-14 10:50:05 +02:00
Taran Vohra 68c842bd4c Translations dropdown should show language spaces that best matches the current space title (#4184) 2026-04-14 13:11:55 +05:30
Nolann B. ecb4292551 Fix llms.txt and llms-full.txt language variants filtering (#4179) 2026-04-13 21:53:42 +02:00
Zeno Kapitein 7fd381c0b1 Hide empty sidebar on OpenAPI pages (#4180) 2026-04-13 20:29:57 +02:00
Zeno Kapitein 709964fe3e Rework page layout (#4013) 2026-04-13 14:48:24 +02:00
Samy Pessé afc7a38bef Version Packages (#4163)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 14:16:46 +02:00
Greg Bergé bbcfd72a6d Fix toolbar design (#4175) 2026-04-13 11:10:34 +00:00
spastorelli 8242f18b8a Extend gitbook/expr std lib with some additional methods (#4173)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2026-04-13 10:19:52 +00:00
Samy Pessé 4f7fba08df Prepare for step.content to be optional (#4174) 2026-04-13 11:47:21 +02:00
conico974 f17e292ba4 Refactor test setup for improved mock restoration (#4176) 2026-04-13 11:23:54 +02:00
Greg Bergé 6e1074ec4d Reduce search debounce (#4170) 2026-04-09 17:43:14 +02:00
Nolann B. c5e5857623 Exclude language variants from llms.txt and llms-full.txt (#4165) 2026-04-08 14:30:31 +02:00
Nolann B. 11df1fd395 Fix MCP getPage tool returning page not found (#4166) 2026-04-08 14:28:39 +02:00
spastorelli 569d4046be Expose a ~gitbook/mcp/auth endpoint for non-VA adaptive content sites (#4155) 2026-04-07 12:50:39 +02:00
Samy Pessé 50653abd08 Improve AI agent detection and readability by outputting 200 for page not found (#4164) 2026-04-04 12:35:43 +02:00
Greg Bergé 84082225af Respect locale to format date in update block (#4160) 2026-04-03 15:36:12 +00:00
Nolann B. 7d882cef5f Fix HTML lang attribute not reflecting site language variants (#4161) 2026-04-03 17:31:37 +02:00
Zeno Kapitein 126d4b78b6 Improve Assistant form controls, i18n, and UX (#4159) 2026-04-03 09:47:19 +02:00
Nolann B. e8224bad6c Support system as a default theme mode (#4151) 2026-04-01 00:39:34 +02:00
Samy Pessé 6063437513 Version Packages (#4158)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-01 00:22:45 +02:00
Nolann B. ab17ff47b0 Update missing CustomIconName (#4157) 2026-04-01 00:09:06 +02:00
Samy Pessé d97b404c7a Version Packages (#4156)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-31 21:14:44 +02:00
Nolann B. 3c141f0231 Bump fontawesome to 7.2.0 (#4154) 2026-03-31 20:38:12 +02:00
Samy Pessé e3d391e01b Version Packages (#4102)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-31 17:01:13 +02:00
Nolann B. 62cfec874a Setup AGENTS.md and CLAUDE.md (#4147) 2026-03-31 16:38:28 +02:00
Nolann B. ccd21e4943 Update fontawesome-pro to 1.0.23 (#4153) 2026-03-31 16:37:13 +02:00
conico974 696d9d0d01 Update vercel dependency to version 50.37.3 (#4152) 2026-03-31 12:07:00 +02:00
Nolann B. b77c4fc5b9 Add getPage MCP tool (#4148) 2026-03-31 11:01:51 +02:00
Zeno Kapitein 7e1ea48080 Revise AI Chat e2e tests (#4150) 2026-03-30 17:58:35 +02:00
Nolann B. b5d6f2e908 Fix translation quality across 11 locales (#4146) 2026-03-30 13:17:03 +02:00
Nolann B. 832b44f0ac Use site-level path for MCP URL instead of space-level path (#4149) 2026-03-30 13:09:25 +02:00
Nolann B. 4909044831 Add Claude Code and Codex connect actions (#4145) 2026-03-30 09:55:45 +02:00
Nolann B. 4ed84c209a Localize announcement message (#4144) 2026-03-30 09:47:17 +02:00
Nolann B. ca01888fe2 Localize footer links title (#4143) 2026-03-30 09:46:37 +02:00
Nolann B. 6855c05781 Localize header link titles (#4142) 2026-03-30 09:46:20 +02:00
Nolann B. ab867eb69c Add top-level locale field to GitBookBaseContext (#4140) 2026-03-27 18:16:19 +01:00
Samy Pessé deabce98ae Sort results by score to mix records and pages (#4077) 2026-03-27 15:25:09 +01:00
Samy Pessé f884477a65 Associate questions asked in the embeddable assistant to the proper channel (#4141) 2026-03-27 15:07:42 +01:00
Nolann B. c56b569a05 Support expand-all options for OpenAPI (#4116) 2026-03-25 23:53:51 +01:00
Peter White 1ba412ff6c Fix OneTrust issue for customer E2E test (#4137) 2026-03-25 17:37:43 +01:00
Peter White 7c2823cd26 Fix full width header alignment (#4138) 2026-03-25 17:01:22 +01:00
Peter White 16bfafe32b Add option to sticky header row in tables (#4134) 2026-03-25 14:04:35 +01:00
Zeno Kapitein da648cabd8 Fix regressions in Assistant UX (#4136) 2026-03-24 18:49:44 +01:00
spastorelli a36e437580 Add login button to not found page for adaptive content site with configured fallback/login URL (#4133) 2026-03-23 16:25:03 +01:00
Samy Pessé d5dd5e7ba1 Include site.id in UTM utm_content for Trademark link (#4135) 2026-03-23 14:08:29 +01:00
Nolann B. c4a2604b4d Fix PDF export crash when page contains an OpenAPI block (#4128) 2026-03-20 11:15:19 +01:00
Nolann B. 7f4e644733 Small OpenAPI improvements (#4131) 2026-03-20 11:15:03 +01:00
Nolann B. 5d3f78b112 Fix flaky internal tests (#4132) 2026-03-20 10:50:20 +01:00
Nolann B. 60bcf43304 Fix allOf merging for annotation-only schemas (#4118) 2026-03-20 10:03:56 +01:00
Samy Pessé b40465e7b7 Fix normalization of URL causing invalid redirections. (#4130) 2026-03-19 19:27:00 +01:00
Samy Pessé 3151864f81 Track events from the proper server URL (#4129) 2026-03-19 18:55:26 +01:00
Greg Bergé 80803c92f2 Fix code block styling (#4127) 2026-03-19 16:07:03 +01:00
Samy Pessé 31e77d4e17 Track events for markdown page views, llms.txt, RSS, MCP connections (#4110) 2026-03-18 17:37:58 +01:00
Taran Vohra 8e5a2aa6e6 pan and zoom controls for mermaid block (#4125) 2026-03-18 20:32:16 +05:30
Taran Vohra fe6f6420e3 Cleanup and remove old preview format using /url/preview (#4117) 2026-03-18 17:50:12 +05:30
Nolann B. aff6285eb9 Deep-merge schema properties in OpenAPISchema (#4124) 2026-03-18 13:09:04 +01:00
conico974 5f132bc979 Add try-catch for error handling and improve logging (#4123) 2026-03-17 17:42:34 +01:00
Nolann B. 4297de3571 Add localizedTitle support for site title (#4115) 2026-03-17 12:17:41 +01:00
Nolann B. 6455f2e3ff Fix parent schema metadata lost during oneOf/anyOf allOf flattening (#4119) 2026-03-17 10:31:12 +01:00
Nolann B. dee52fa80a Fix schema title display stripping spaces (#4120) 2026-03-17 10:27:56 +01:00
spastorelli 4ac29817a2 Expose a ~gitbook/auth/login URL to handle redirection to upstream auth with current location passed (#4113) 2026-03-16 16:00:31 +01:00
spastorelli f09ca60930 Fix crash for PDF route when the page include an Ask AI button (#4121) 2026-03-16 15:41:16 +01:00
Greg Bergé a7c0f7251b Allow <br> in OpenAPI descriptions (#4112) 2026-03-13 11:51:50 +01:00
Greg Bergé efc6117665 Fix code block layout (#4108) 2026-03-13 10:24:18 +01:00
Taran Vohra 6c613d0df3 Fix auth redirects with the new preview URL (#4111) 2026-03-13 13:52:09 +05:30
Taran Vohra 45d32b5084 Site Preview URLs (#4096) 2026-03-12 20:27:59 +05:30
conico974 0f62a629b4 Handle unexpected errors in getExposableError (#4109) 2026-03-12 15:54:01 +01:00
Peter White 7ab63516bd Import site redirect patterns from @gitbook/api (#4107) 2026-03-12 10:06:33 +00:00
Nolann B. 9dd2db6678 Support x-gitbook-token-placeholder as TryIt prefill fallback for basic auth (#4105) 2026-03-11 20:35:13 +01:00
Nolann B. 4787b7e910 Use localized titles/descriptions for site entities (#4106) 2026-03-11 18:25:18 +01:00
Taran Vohra be02e33ada Fix an issue where source path containing special characters were not getting resolved to a site redirect (#4104) 2026-03-11 21:33:42 +05:30
311 changed files with 12310 additions and 5425 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Variant dropdown not showing in GitBook embed
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support variant selector in hidden section
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/icons": minor
"gitbook": patch
---
Update icon usage to render svg symbols rather than file.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix embeddable search links
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces/sections in the llms.txt.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Pass visitor session to Ask AI.
@@ -64,6 +64,7 @@ runs:
run: bun run turbo build:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
shell: bash
- name: Upload the DO worker
@@ -19,6 +19,9 @@ inputs:
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
outputs:
deployment-url:
description: "Deployment URL"
@@ -59,6 +62,17 @@ runs:
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Inject build env vars
if: ${{ inputs.environment == 'preview' }}
shell: bash
run: |
HEAD_SHA=$(git rev-parse HEAD)
echo "resolved HEAD_SHA: $HEAD_SHA"
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
@@ -66,6 +80,7 @@ runs:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
+1 -5
View File
@@ -40,6 +40,7 @@ jobs:
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
headSha: ${{ github.event.pull_request.head.sha || github.sha }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
@@ -136,7 +137,6 @@ 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
visual-testing-v2-cloudflare:
@@ -161,7 +161,6 @@ 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
visual-testing-customers-v2:
@@ -186,7 +185,6 @@ 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-vercel
visual-testing-customers-v2-cloudflare:
@@ -211,7 +209,6 @@ 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-cloudflare
browserless-testing-v2-vercel:
@@ -235,7 +232,6 @@ 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)
+3 -3
View File
@@ -26,9 +26,10 @@ jobs:
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
run: |
corepack enable npm
corepack install -g npm@latest
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -45,4 +46,3 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
+75 -1
View File
@@ -1,3 +1,77 @@
# AGENTS Instructions
- After making code changes, run `bun format`.
## Commands
```bash
bun install # Install dependencies
bun dev # Start dev server (all packages)
bun run build # Build all packages
bun run format # Format with Biome (run after every change)
bun run typecheck # Type-check all packages
bun run unit # Run unit tests
```
## Development
The dev server proxies published GitBook sites locally. After `bun dev`, access any published site at:
```
http://localhost:3000/url/<published-gitbook-url>
```
Examples:
- `http://localhost:3000/url/gitbook.com/docs`
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`
## Architecture
```
packages/
gitbook/ # Main Next.js app
src/
app/ # Next.js App Router (sites/)
components/ # React components
lib/ # Server utilities, data fetching
intl/ # Internationalization (translations/)
openapi-parser/ # OpenAPI 3.0/3.1/Swagger parser
react-openapi/ # OpenAPI rendering components
react-contentkit/ # ContentKit component rendering
embed/ # Embeddable GitBook components
shared/ # Shared utilities
icons/ # Icon assets
fonts/ # Font assets
colors/ # Color tokens
expr/ # GitBook expression evaluator
cache-do/ # Cloudflare DO cache
cache-tags/ # Cache tag utilities
```
## Testing
```bash
bun run unit # Unit tests via bun test (not vitest)
bun run e2e # Playwright e2e tests (requires built app)
```
Run a specific test file:
```bash
cd packages/gitbook && bun test src/lib/cache.test.ts
```
## Changesets
After committing code changes, create a changeset for the affected package:
```md
---
"gitbook": patch
---
Provide a short description of the change.
```
Save as `.changeset/<name>.md`, then commit it separately with message: `changeset`
## Formatting
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+1
View File
@@ -23,6 +23,7 @@
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"gitbook/tsconfig.json",
"**/*.css"
]
},
+349 -1628
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -7,8 +7,8 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.8.16",
"vercel": "^50.31.1"
"turbo": "^2.9.6",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
"overrides": {
@@ -17,7 +17,9 @@
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2"
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
},
"private": true,
"scripts": {
@@ -41,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.167.0",
"@gitbook/api": "0.180.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -53,11 +55,13 @@
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
"usehooks-ts": "^3.1.1",
"vercel": "^50.26.1"
}
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.3": "patches/next@16.2.3.patch"
}
}
+10
View File
@@ -1,5 +1,15 @@
# @gitbook/embed
## 0.5.0
### Minor Changes
- 88c38fa: Add Search tab to Docs Embed, refactor search
### Patch Changes
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
## 0.4.0
### Minor Changes
+30 -9
View File
@@ -2,8 +2,9 @@
Embed your GitBook docs in your product or website.
The Docs Embed can contain two tabs:
The Docs Embed can contain three tabs:
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
- **Search**: A search-focused surface for quickly finding pages and asking scoped questions
- **Docs**: A browser for navigating your documentation site
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
@@ -40,7 +41,7 @@ GitBook('configure', {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -72,6 +73,7 @@ const gitbook = createGitBook({
// Create an iframe and get its URL
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({
colorScheme: 'dark', // Optional: force the embed to render in dark mode
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
@@ -92,7 +94,7 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -122,11 +124,12 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame
colorScheme="dark"
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'docs']}
tabs={['assistant', 'search', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
@@ -150,7 +153,7 @@ import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
// ...
}
```
@@ -178,7 +181,7 @@ function MyComponent() {
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
@@ -195,7 +198,7 @@ function MyComponent() {
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
@@ -228,10 +231,10 @@ Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Type**: `('assistant' | 'search' | 'docs')[]`
```javascript
tabs: ['assistant', 'docs']
tabs: ['assistant', 'search', 'docs']
```
### `closeButton`
@@ -457,6 +460,24 @@ visitor: {
}
```
### `colorScheme`
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```javascript
colorScheme: 'dark'
```
### `button`
Available in: Standalone script only
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.4.0",
"version": "0.5.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -35,4 +35,17 @@ describe('createGitBook.getFrameURL', () => {
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
it('adds an explicit color scheme override when requested', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
colorScheme: 'dark',
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('theme')).toBe('dark');
});
});
@@ -8,6 +8,12 @@ export type CreateGitBookOptions = {
};
export type GetFrameURLOptions = {
/**
* Override the color scheme used by the embedded docs.
* When omitted, the embed follows the iframe's CSS `color-scheme`.
*/
colorScheme?: 'light' | 'dark';
/**
* Authentication to use for the frame.
*/
@@ -42,6 +48,10 @@ export function createGitBook(options: CreateGitBookOptions) {
const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.colorScheme) {
url.searchParams.set('theme', frameOptions.colorScheme);
}
if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token);
}
@@ -64,7 +64,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
+1 -1
View File
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
*/
export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs')[];
tabs: ('assistant' | 'docs' | 'search')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
+7 -2
View File
@@ -19,12 +19,13 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) {
const {
className,
colorScheme,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
tabs = ['assistant', 'search', 'docs'],
trademark = true,
closeButton = false,
assistantName,
@@ -34,7 +35,10 @@ export function GitBookFrame(props: GitBookFrameProps) {
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
const frameURL = useMemo(
() => gitbook.getFrameURL({ visitor, colorScheme }),
[gitbook, visitor, colorScheme]
);
useEffect(() => {
if (frameRef.current) {
@@ -73,6 +77,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
width="100%"
height="100%"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+4 -1
View File
@@ -64,7 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
trademark: true,
};
@@ -101,6 +101,9 @@ function getIframe() {
widgetIframe?.remove();
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/expr
## 1.2.5
### Patch Changes
- 8242f18: Extend gitbook/expr std lib with some additional methods
## 1.2.4
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.4",
"version": "1.2.5",
"type": "module",
"exports": {
".": {
@@ -25,6 +25,7 @@ describe('autocomplete', () => {
description: 'An array of string',
items: SymbolString(),
});
const visitorClaimsKeyPropStringSymbol = SymbolString({ name: 'key' });
const symbols = {
visitor: SymbolObject({
name: 'visitor',
@@ -33,7 +34,7 @@ describe('autocomplete', () => {
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
key: visitorClaimsKeyPropStringSymbol,
flags: SymbolObject({
name: 'flags',
properties: {
@@ -168,8 +169,12 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
},
},
@@ -227,8 +232,12 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.role.length',
'visitor.claims.role.at',
'visitor.claims.role.startsWith',
'visitor.claims.role.endsWith',
'visitor.claims.role.includes',
'visitor.claims.role.toLowerCase',
'visitor.claims.role.toUpperCase',
'visitor.claims.role.trim',
],
},
},
@@ -246,13 +255,47 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.key.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolNumber({
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
}),
ref: 'visitor.claims.key.length',
parentRef: 'visitor.claims.key',
childrenRefs: [],
},
},
...visitorClaimsKeyPropStringSymbol.methods.map<AutocompleteSymbolSuggestion>(
(method) => ({
type: 'symbol',
symbol: {
definition: method,
ref: `visitor.claims.key.${method.name}`,
parentRef: 'visitor.claims.key',
childrenRefs: [],
},
})
),
],
},
{
expressionWithCursor: 'visitor.claims.h<cur>',
expectedSuggestions: [
@@ -54,12 +54,54 @@ describe('ExpressionRuntime', () => {
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array includes',
condition: 'reviews.includes("approved")',
inputs: { reviews: ['pending', 'approved'] },
expectedResult: true,
},
{
scenario: 'array map',
condition: '[1, 2, 3].map(n => n * x)',
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
{
scenario: 'string startsWith',
condition: 'user.role.startsWith("ad")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string endsWith',
condition: 'user.role.endsWith("min")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string includes',
condition: 'user.role.includes("dm")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string toLowerCase',
condition: 'user.role.toLowerCase() === "admin"',
inputs: { user: { role: 'ADMIN' } },
expectedResult: true,
},
{
scenario: 'string toUpperCase',
condition: 'user.role.toUpperCase() === "ADMIN"',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string trim',
condition: 'user.role.trim() === "admin"',
inputs: { user: { role: ' admin ' } },
expectedResult: true,
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
@@ -255,8 +255,12 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
});
@@ -301,8 +305,12 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.flags.FLAG1.length',
'visitor.claims.flags.FLAG1.at',
'visitor.claims.flags.FLAG1.startsWith',
'visitor.claims.flags.FLAG1.endsWith',
'visitor.claims.flags.FLAG1.includes',
'visitor.claims.flags.FLAG1.toLowerCase',
'visitor.claims.flags.FLAG1.toUpperCase',
'visitor.claims.flags.FLAG1.trim',
],
});
@@ -318,8 +326,12 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.flags.FLAG2.length',
'visitor.claims.flags.FLAG2.at',
'visitor.claims.flags.FLAG2.startsWith',
'visitor.claims.flags.FLAG2.endsWith',
'visitor.claims.flags.FLAG2.includes',
'visitor.claims.flags.FLAG2.toLowerCase',
'visitor.claims.flags.FLAG2.toUpperCase',
'visitor.claims.flags.FLAG2.trim',
],
});
+58
View File
@@ -156,6 +156,31 @@ const StandardLibrary: Partial<
members: [SymbolString(), SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'startsWith',
description: `Returns true if the given characters are found at the beginning of the string, including when searchString
is an empty string. Otherwise returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
args: [
SymbolString({
name: 'searchString',
description: `The characters to be searched for at the start of this string. Cannot be a regex.
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes startsWith() to search for
the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'position',
description: `The start position at which searchString is expected to be found
(the index of searchString's first character). Defaults to 0.`,
})
),
],
returns: SymbolBoolean({
description: `true if the given characters are found at the beginning of the string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'endsWith',
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
@@ -205,6 +230,39 @@ const StandardLibrary: Partial<
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'toLowerCase',
description:
'Returns the value of the string converted to lower case. toLowerCase() does not affect the value of the string str itself.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
args: [],
returns: SymbolString({
description:
'A new string representing the calling string converted to lower case.',
}),
}),
SymbolFunction({
name: 'toUpperCase',
description:
'Returns the value of the string converted to uppercase. toUpperCase() does not affect the value of the string str itself.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
args: [],
returns: SymbolString({
description:
'A new string representing the calling string converted to upper case.',
}),
}),
SymbolFunction({
name: 'trim',
description:
'Returns new string representing str stripped of whitespace from both its beginning and end.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim',
args: [],
returns: SymbolString({
description:
'A new string representing str stripped of whitespace from both its beginning and end.',
}),
}),
],
},
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
+1 -1
View File
@@ -36,4 +36,4 @@ screenshots/
# cloudflare
.open-next
.wrangler
worker-configuration.d.ts
worker-configuration.d.ts
+89
View File
@@ -1,5 +1,94 @@
# gitbook
## 0.25.1
### Patch Changes
- aa1fc24: Add support for sticky first column in tables
## 0.25.0
### Minor Changes
- 88c38fa: Add Search tab to Docs Embed, refactor search
- 709964f: Rework page layout
### Patch Changes
- 789c2fa: Adjust empty TOC logic and layout
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
- 7fd381c: Hide empty sidebar on OpenAPI pages
- 723e579: Fix rendering multi-expandable block in a column
- 68c842b: Translations dropdown should show language spaces that best matches the current space title
- Updated dependencies [8d2a95b]
- Updated dependencies [88c38fa]
- @gitbook/embed@0.5.0
## 0.24.0
### Minor Changes
- 50653ab: Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
- 50653ab: Improve AI agent detection and readability by outputting 200 for page not found.
### Patch Changes
- 126d4b7: Improve Assistant form controls
- Updated dependencies [8242f18]
- @gitbook/expr@1.2.5
- @gitbook/react-openapi@1.5.12
## 0.23.2
### Patch Changes
- Updated dependencies [ab17ff4]
- @gitbook/icons@0.4.6
- @gitbook/react-contentkit@0.7.15
## 0.23.1
### Patch Changes
- Updated dependencies [3c141f0]
- @gitbook/icons@0.4.5
- @gitbook/react-contentkit@0.7.14
## 0.23.0
### Minor Changes
- 8e5a2aa: Pan and zoom controls for mermaid diagrams
### Patch Changes
- b77c4fc: Add getPage MCP tool
- 31e77d4: Track new events for site analytics.
- f884477: Associate questions asked in the embeddable assistant to the proper channel.
- deabce9: Sort results by score to mix records and pages.
- 4909044: Add Claude Code and Codex connect actions
- da648ca: Fix regressions in Assistant UX
- 3ce7e52: Fix Variant dropdown not showing in GitBook embed
- 7f4e644: Small OpenAPI Improvements
- 4787b7e: Use localized titles/descriptions for site entities
- 7ab6351: Import site redirect patterns from @gitbook/api
- b40465e: Fix normalization of URL causing invalid redirections.
- b5d6f2e: Fix translation quality across 11 locales
- 3151864: Track events from the proper server URL.
- 16bfafe: Add option to sticky header row in tables
- 7c2823c: Fix full width header alignment
- c4a2604: Fix PDF export crash when page contains an OpenAPI block
- c56b569: Support expand-all options for OpenAPI
- Updated dependencies [7f4e644]
- Updated dependencies [b5d6f2e]
- Updated dependencies [9dd2db6]
- Updated dependencies [c56b569]
- Updated dependencies [ccd21e4]
- @gitbook/react-openapi@1.5.11
- @gitbook/openapi-parser@3.0.11
- @gitbook/icons@0.4.4
- @gitbook/react-contentkit@0.7.13
## 0.22.3
### Patch Changes
+30 -11
View File
@@ -1,5 +1,18 @@
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
const CLOSED_ONETRUST_COOKIE_BANNER = [
{
name: '__gitbook_cookie_granted',
value: 'no',
},
{
name: 'OptanonAlertBoxClosed',
value: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(),
},
];
/** A list of test cases to run on the customers' docs sites. */
const testCases: TestsCase[] = [
{
@@ -97,11 +110,12 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.midas.app',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.keeper.io',
contentBaseURL: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
// Disabling for now, one of the image is making the tests timeout
// {
// name: 'docs.keeper.io',
// contentBaseURL: 'https://docs.keeper.io',
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
// },
{
name: 'adiblar.gitbook.io',
contentBaseURL: 'https://adiblar.gitbook.io',
@@ -209,11 +223,12 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.soniclabs.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.meshchain.ai',
contentBaseURL: 'https://docs.meshchain.ai',
tests: [{ name: 'Home', url: '/' }],
},
// This one redirects to binance now
// {
// name: 'docs.meshchain.ai',
// contentBaseURL: 'https://docs.meshchain.ai',
// tests: [{ name: 'Home', url: '/' }],
// },
{
name: 'docs.thousandeyes.com',
contentBaseURL: 'https://docs.thousandeyes.com',
@@ -233,7 +248,11 @@ const testCases: TestsCase[] = [
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
tests: [
{ name: 'Home', url: '/' },
{
name: 'Home',
url: '/',
cookies: CLOSED_ONETRUST_COOKIE_BANNER,
},
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
+133 -28
View File
@@ -3,11 +3,11 @@ import {
CustomizationBackground,
CustomizationCorners,
CustomizationDefaultMonospaceFont,
CustomizationDefaultThemeMode,
CustomizationDepth,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
@@ -16,7 +16,7 @@ import jwt from 'jsonwebtoken';
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
import { getSiteAPIToken } from '../tests/utils';
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
import {
type Test,
type TestsCase,
@@ -36,7 +36,7 @@ import {
waitForNotFound,
} from './util';
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
@@ -69,7 +69,6 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.getByTestId('search-input');
@@ -83,8 +82,6 @@ const searchTestCases: Test[] = [
});
const pageResults = await page.getByTestId('search-page-result').all();
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
},
},
@@ -95,7 +92,6 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.None,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await page.keyboard.press('ControlOrMeta+K');
@@ -149,7 +145,6 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
const searchInput = page.locator('css=[data-testid="search-input"]');
@@ -186,7 +181,6 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await page.keyboard.press('ControlOrMeta+I');
@@ -203,7 +197,6 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
}),
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await page.getByTestId('ai-chat-button').click();
@@ -220,7 +213,6 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant,
},
})}&ask=`,
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
await expect(page.getByTestId('search-input')).not.toBeFocused();
@@ -238,7 +230,6 @@ 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();
@@ -327,7 +318,6 @@ const testCases: TestsCase[] = [
await expect(navigationLink).toBeVisible();
},
},
...searchTestCases,
{
name: 'Not found',
url: 'content-not-found',
@@ -623,7 +613,6 @@ const testCases: TestsCase[] = [
url: '',
run: waitForCookiesDialog,
},
...searchTestCases,
{
name: 'Not found',
url: 'content-not-found',
@@ -709,10 +698,30 @@ const testCases: TestsCase[] = [
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
},
},
{
name: 'Show error when missing token',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
// Intentionally not setting the token to test error handling when the token is missing
const searchParams = new URLSearchParams();
searchParams.set('limit', '10');
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
},
screenshot: false,
run: async (page, response) => {
expect(response).not.toBeNull();
expect(response?.status()).toBe(400);
await expect(page.getByText('Missing API token')).toBeVisible();
},
},
],
},
{
name: 'Site Preview',
name: 'Site Previews',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
tests: [
{
@@ -725,7 +734,7 @@ const testCases: TestsCase[] = [
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
@@ -740,7 +749,7 @@ const testCases: TestsCase[] = [
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/preview/${data.site}/?${searchParams.toString()}`;
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
@@ -750,10 +759,74 @@ const testCases: TestsCase[] = [
const sectionTabLinks = sectionTabs.getByRole('link');
for (const link of await sectionTabLinks.all()) {
const href = await link.getAttribute('href');
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
expect(href?.includes('/preview/site_p4Xo4')).toBeTruthy();
}
},
},
{
name: 'With customization cookie',
url: async () => {
const data = await getSiteAPIToken(
'https://gitbook.gitbook.io/test-gitbook-open/'
);
const searchParams = new URLSearchParams();
searchParams.set('token', data.apiToken);
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
// Trademark exists by default
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
1
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
// Go to another page with the customization query to disable the trademark
const pageBlocks = new URL(page.url());
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
pageBlocks.search = getCustomizationURL({
trademark: {
enabled: false,
},
}).slice(1);
await page.goto(pageBlocks.toString());
// No trademark because customization is disabled
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 1, name: 'Blocks' })
).toBeVisible();
const pageBlocksCode = new URL(page.url());
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
pageBlocksCode.search = '';
await page.goto(pageBlocksCode.toString());
// The trademark should not be visible because the cookie is still set,
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 1, name: 'Code' })
).toBeVisible();
},
},
{
name: 'Redirect to app for authentication when missing token',
url: async () => {
const data = await getSiteAPIToken('https://gitbook.com/docs');
const searchParams = new URLSearchParams();
// Intentionally not setting the token to test redirection for authentication
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
},
screenshot: false,
run: async (page) => {
await page.waitForURL(
(url) =>
url.host === 'app.gitbook.com' && url.pathname.includes('/preview/auth')
);
},
},
],
},
{
@@ -793,9 +866,15 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Search & AI',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: searchTestCases,
},
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
fullPage: true,
tests: [
{
name: 'Text',
@@ -1019,7 +1098,7 @@ const testCases: TestsCase[] = [
name: 'With cover for dark mode',
url: `page-options/page-with-dark-cover${getCustomizationURL({
themes: {
default: CustomizationThemeMode.Dark,
default: CustomizationDefaultThemeMode.Dark,
toggeable: false,
},
})}`,
@@ -1306,7 +1385,7 @@ const testCases: TestsCase[] = [
).toBeVisible();
const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
@@ -1326,7 +1405,7 @@ const testCases: TestsCase[] = [
).toBeVisible();
const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
@@ -1337,13 +1416,21 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Redirect to SSO page',
name: 'Basic redirect',
url: 'a/redirect/to/sso',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO');
},
screenshot: false,
},
{
name: 'Complex wildcard with special characters',
url: 'foo/bar/baz/123456789-welcome-to-gitbook-%22%20target=%22_blank',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SEO');
},
screenshot: false,
},
],
},
{
@@ -2069,7 +2156,7 @@ const testCases: TestsCase[] = [
},
{
name: 'Docs Embed - Basic',
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
tests: [
{
name: 'Standalone UX',
@@ -2116,7 +2203,7 @@ const testCases: TestsCase[] = [
},
{
name: 'Docs Embed - Assistant + Docs',
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
tests: [
{
@@ -2144,7 +2231,7 @@ const testCases: TestsCase[] = [
run: async (page) => {
await page.evaluate(() => {
const GitBook = window.GitBook as unknown as GitBookStandalone;
GitBook('navigateToPage', '/getting-started/quickstart');
GitBook('navigateToPage', '/text-page');
});
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
const iframe = page.frameLocator('#gitbook-widget-iframe');
@@ -2153,7 +2240,7 @@ const testCases: TestsCase[] = [
});
await expect(iframe.owner()).toHaveAttribute(
'src',
expect.stringContaining('getting-started/quickstart')
expect.stringContaining('text-page')
);
},
},
@@ -2214,7 +2301,7 @@ const testCases: TestsCase[] = [
onClick: () => {
const GitBook =
window.GitBook as unknown as GitBookStandalone;
GitBook('navigateToPage', '/getting-started/quickstart');
GitBook('navigateToPage', '/text-page');
},
},
{
@@ -2255,7 +2342,7 @@ const testCases: TestsCase[] = [
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible();
await expect(iframe.owner()).toHaveAttribute(
'src',
expect.stringContaining('getting-started/quickstart')
expect.stringContaining('text-page')
);
await expect(actions.nth(1)).toHaveAccessibleName('Open external link');
@@ -2339,6 +2426,12 @@ const testCases: TestsCase[] = [
name: 'Docs only',
url: '',
run: async (page) => {
await page.evaluate(() => {
const GitBook = window.GitBook as unknown as GitBookStandalone;
GitBook('configure', {
tabs: ['docs'],
});
});
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
const iframe = page.frameLocator('#gitbook-widget-iframe');
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
@@ -2350,6 +2443,12 @@ const testCases: TestsCase[] = [
name: 'Table of contents',
url: '',
run: async (page) => {
await page.evaluate(() => {
const GitBook = window.GitBook as unknown as GitBookStandalone;
GitBook('configure', {
tabs: ['docs'],
});
});
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
const iframe = page.frameLocator('#gitbook-widget-iframe');
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
@@ -2365,6 +2464,12 @@ const testCases: TestsCase[] = [
name: 'Open in new tab',
url: '',
run: async (page) => {
await page.evaluate(() => {
const GitBook = window.GitBook as unknown as GitBookStandalone;
GitBook('configure', {
tabs: ['docs'],
});
});
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
const iframe = page.frameLocator('#gitbook-widget-iframe');
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
+33 -12
View File
@@ -6,6 +6,7 @@ import {
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
CustomizationDefaultThemeMode,
CustomizationDepth,
type CustomizationHeaderItem,
CustomizationHeaderPreset,
@@ -16,7 +17,6 @@ import {
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationTheme,
CustomizationThemeMode,
type CustomizationThemedColor,
type SiteCustomizationSettings,
SiteExternalLinksTarget,
@@ -75,6 +75,10 @@ export type TestsCase = {
skip?: boolean;
tests: Array<Test>;
contentBaseURL?: string;
/**
* Whether screenshots in this test case should capture the full scrollable page by default.
*/
fullPage?: boolean;
};
export const allLocales: CustomizationLocale[] = [
@@ -84,9 +88,9 @@ export const allLocales: CustomizationLocale[] = [
CustomizationLocale.Zh,
];
export const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
export const allThemeModes: CustomizationDefaultThemeMode[] = [
CustomizationDefaultThemeMode.Light,
CustomizationDefaultThemeMode.Dark,
];
export const allTintColors: Array<{
@@ -142,6 +146,11 @@ export const headerLinks: CustomizationHeaderItem[] = [
},
];
type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string };
type IconStateWindow = Window & {
__ICONS_STATES__?: Record<string, IconURLState>;
};
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({
@@ -240,9 +249,9 @@ export function runTestCases(testCases: TestsCase[]) {
.intercom-lightweight-app {
display: none !important;
}
`,
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
if (screenshotOptions?.waitForTOCScrolling !== false) {
@@ -340,7 +349,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
groups: [],
},
themes: {
default: CustomizationThemeMode.Light,
default: CustomizationDefaultThemeMode.System,
toggeable: true,
},
pdf: {
@@ -392,11 +401,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const iconWindow = window as IconStateWindow;
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
iconWindow.__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
@@ -429,6 +436,20 @@ export async function waitForIcons(page: Page) {
return true;
}
const svgSymbol = icon.querySelector('[data-testid="symbol-use"]');
if (svgSymbol) {
if (icon.dataset.gbIconSymbolState === 'loaded') {
return true;
}
const href = svgSymbol.getAttribute('href') ?? svgSymbol.getAttribute('xlink:href');
if (!href?.startsWith('#')) {
return false;
}
return document.getElementById(href.slice(1)) instanceof SVGElement;
}
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
+23 -2
View File
@@ -1,10 +1,27 @@
// @ts-check
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
let deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
? undefined
: process.env.GITBOOK_HEAD_SHA || 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
const { VERCEL_TARGET_ENV } = process.env;
// Because preview, staging and prod shares the same SHA, the deployment will fail if we don't prefix it with the environment name.
if (VERCEL_TARGET_ENV === 'preview') {
deploymentId = `t-${deploymentId}`;
} else if (VERCEL_TARGET_ENV === 'staging') {
deploymentId = `s-${deploymentId}`;
} else if (VERCEL_TARGET_ENV === 'production') {
deploymentId = `p-${deploymentId}`;
}
/**
* @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
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -18,15 +35,18 @@ const nextConfig = {
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
},
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_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,
@@ -39,6 +59,7 @@ const nextConfig = {
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
@@ -2,7 +2,7 @@
"main": "default.js",
"name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-04-02",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
@@ -92,7 +92,8 @@
},
"staging": {
"vars": {
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
@@ -136,7 +137,8 @@
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
// This means that we'll have up to 100 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "100",
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
"NEXT_PRIVATE_DEBUG_CACHE": "true"
},
"r2_buckets": [
{
@@ -1,7 +1,7 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-04-02",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
@@ -29,6 +29,7 @@ export class GitbookIncrementalCache implements IncrementalCache {
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
const cacheKey = this.getR2Key(key, cacheType);
console.log(`[GitbookIncrementalCache] Getting cache for key: ${cacheKey}`, key, cacheType);
const r2 = getCloudflareContext().env[BINDING_NAME];
if (!r2) throw new Error('No R2 bucket');
@@ -1,12 +1,15 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
// export default withRegionalCache(new GitbookIncrementalCache(), {
// mode: 'long-lived',
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
// bypassTagCacheOnCacheHit: false,
// //TODO: remove, reducing cache ttl of regional cache to help debugging
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// // We don't want to update the cache entry on every cache hit
// shouldLazilyUpdateOnCacheHit: false,
// });
//TODO: reenable regional cache once we know what's going on
export default new GitbookIncrementalCache();
+25 -16
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.22.3",
"version": "0.25.1",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -19,17 +19,20 @@
"@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",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
"@vercel/agent-readability": "^0.4.0",
"ai": "^4.2.2",
"assert-never": "catalog:",
"bidc": "catalog:",
@@ -37,22 +40,23 @@
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"flexsearch": "^0.8.212",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"leven": "^4.1.0",
"mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.12.3",
"mermaid": "^11.14.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "15.4.11",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -60,9 +64,10 @@
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
@@ -95,8 +100,8 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
@@ -108,18 +113,18 @@
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "^50.15.1",
"wrangler": "^4.43.0",
"vercel": "50.37.3",
"wrangler": "^4.79.0",
"rss-parser": "^3.13.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
"build": "next build --webpack",
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "opennextjs-cloudflare build",
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
"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",
@@ -135,5 +140,9 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
@@ -0,0 +1,30 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
if (!context.site.urls.login) {
return NextResponse.redirect(noLoginFallbackURL);
}
try {
const loginURL = new URL(context.site.urls.login);
const location = request.nextUrl.searchParams.get('location');
if (location) {
loginURL.searchParams.set('location', location);
}
return NextResponse.redirect(loginURL);
} catch (_error) {
return NextResponse.redirect(noLoginFallbackURL);
}
}
@@ -0,0 +1,36 @@
import {
type RouteLayoutParams,
getDynamicSiteContext,
getSiteURLDataFromParams,
} from '@/app/utils';
import { getVisitorAuthBasePath } from '@/lib/data';
import { getVisitorAuthCookieName } from '@/lib/visitors';
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Clear the site-scoped auth session cookies and redirect to the site root.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const resolvedParams = await params;
const [{ context }, siteURLData, cookieStore] = await Promise.all([
getDynamicSiteContext(resolvedParams),
getSiteURLDataFromParams(resolvedParams),
cookies(),
]);
cookieStore.delete(
getVisitorAuthCookieName(
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
)
);
// TODO: Redirect to the site root for now. Once the API supports it,
// optionally redirect to a logoutURL (e.g when needing to logout from upstream auth too)
// when defined in visitor auth settings.
return NextResponse.redirect(context.linker.toAbsoluteURL(context.linker.toPathInSite('')));
}
@@ -5,6 +5,7 @@ import {
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
@@ -18,12 +19,14 @@ export default async function RootLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
const forcedTheme = await getThemeFromMiddleware();
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
forcedTheme={forcedTheme}
>
{children}
</EmbeddableRootLayout>
@@ -0,0 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableSearchPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const dynamic = 'force-static';
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
return <EmbeddableSearchPage context={context} />;
}
@@ -0,0 +1,12 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { handleMcpRequest } from '../handler';
async function handler(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp/auth');
}
export { handler as GET, handler as POST };
@@ -0,0 +1,227 @@
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
import { resolvePagePath } from '@/lib/pages';
import { joinPathWithBaseURL } from '@/lib/paths';
import { findSiteSpaceBy, findSiteSpaceByUrl } 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';
export async function handleMcpRequest(
rawRequest: NextRequest,
params: RouteLayoutParams,
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
) {
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
if (!context.customization.pageActions.mcp) {
return new Response('Not Found', { status: 404 });
}
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
requestURL.search = rawRequest.nextUrl.search;
const request = new Request(requestURL, rawRequest);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: context.site.id,
events: [
{
type: 'mcp_request',
location: {
displayContext: SiteInsightsDisplayContext.Server,
},
},
],
request,
})
);
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'search_type_query',
query,
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
},
},
],
request,
})
);
return {
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 === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
const siteUrl = context.siteSpace.urls.published;
server.tool(
'getPage',
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
{
url: z
.string()
.describe('The URL of the page to fetch')
.transform((value, ctx) => {
if (URL.canParse(value)) {
return value;
}
if (URL.canParse(`https://${value}`)) {
return `https://${value}`;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
});
return z.NEVER;
}),
},
async ({ url }) => {
try {
const match = findSiteSpaceByUrl(context.structure, url);
if (!match) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const revision = await throwIfDataError(
dataFetcher.getRevision({
spaceId: match.siteSpace.space.id,
revisionId: match.siteSpace.space.revision,
})
);
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
if (!resolved) {
return {
content: [{ type: 'text', text: `Page not found: "${url}"` }],
isError: true,
};
}
const markdown = await getMarkdownForPageInSpace(
context,
match.siteSpace,
resolved.page
);
waitUntil(
trackServerInsightsEvents({
organizationId: context.organizationId,
siteId: site.id,
events: [
{
type: 'page_view',
location: {
displayContext: SiteInsightsDisplayContext.Mcp,
page: resolved.page.id,
space: match.siteSpace.space.id,
revision: match.siteSpace.space.revision,
},
},
],
request,
})
);
return { content: [{ type: 'text', text: markdown }] };
} catch (error) {
const exposable = getExposableError(error);
return {
content: [{ type: 'text', text: exposable.message }],
isError: true,
};
}
}
);
},
{},
{
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
return mcpHandler(request);
}
@@ -0,0 +1,12 @@
import type { RouteLayoutParams } from '@/app/utils';
import type { NextRequest } from 'next/server';
import { handleMcpRequest } from './handler';
async function handler(
rawRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp');
}
export { handler as GET, handler as POST };
@@ -4,61 +4,53 @@ import type {
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { GitBookBaseContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import { findSiteSpaceBy, getLocalizedTitle } 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(),
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
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;
})(),
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
query,
scope,
})
),
throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
siteShareKey: siteURLData.shareKey,
})
),
]);
const results = searchResults
.map((resultItem) => {
.flatMap((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
@@ -66,97 +58,147 @@ export async function POST(request: NextRequest) {
title: resultItem.title,
description: resultItem.description,
href: resultItem.url,
score: resultItem.score,
};
return result;
return [{ score: resultItem.score, items: [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, {
return resultItem.pages.map((pageItem) => ({
score: pageItem.score,
items: transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
})
);
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
}));
})
.flat(2);
.sort((a, b) => b.score - a.score)
.flatMap((group) => group.items);
return NextResponse.json(results);
}
function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
function transformSitePageResult(args: {
asEmbeddable: boolean;
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
siteSpace?: SiteSpace;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup | null;
}): OrderedComputedResult[] {
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
args;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
if (siteSectionGroup) {
breadcrumbs.push({
icon: siteSectionGroup.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
});
}
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
if (siteSection) {
breadcrumbs.push({
icon: siteSection.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
});
}
if (
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection.siteSpaces?.filter(
(candidate) => candidate.space.language === space.space.language
).length > 1
).length ?? 0) > 1 &&
siteSpace
) {
breadcrumbs.push({
label: getLocalizedTitle(siteSpace, currentLanguage),
});
}
breadcrumbs.push(
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
}))
);
const pageHref = !spaceURL
? linker.toPathInSpace(pageItem.path)
: asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
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),
href: pageHref,
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),
score: pageItem.score,
breadcrumbs,
};
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,
})) ?? [];
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(pageItem.path);
return [page, ...pageSections];
if (spaceURL) {
if (asEmbeddable) {
sectionHref = toEmbeddableLinkForPublishedContent(
linker,
spaceURL,
section.path
);
} else {
sectionHref = linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, section.path)
);
}
}
return {
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: sectionHref,
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
};
}) ?? [];
// Find the best-scoring section to use as a body preview on the page result.
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
(best, section) => (!best || section.score > best.score ? section : best),
undefined
);
if (bestSection) {
page.bestSection = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return [page];
}
@@ -10,5 +10,6 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context, { withMarkdownPages: true });
return serveLLMsTxt(context);
}
@@ -125,7 +125,7 @@ export async function GET(
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
closeButton: useCustomTrigger
});
@@ -0,0 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableSearchPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const dynamic = 'force-static';
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
return <EmbeddableSearchPage context={context} />;
}
@@ -0,0 +1,16 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
) {
const { question: encodedQuestion } = await params;
const { context } = await getStaticSiteContext(await params);
const question = decodeURIComponent(encodedQuestion);
return serveAskMarkdown(context, question);
}
@@ -1,127 +0,0 @@
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';
async function handler(
nextRequest: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, site } = context;
const mcpHandler = createMcpHandler(
(server) => {
server.tool(
'searchDocumentation',
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
{
query: z.string(),
},
async ({ query }) => {
const results = await throwIfDataError(
dataFetcher.searchSiteContent({
organizationId: context.organizationId,
siteId: site.id,
query,
scope: { mode: 'all' },
})
);
// 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((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 === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
)
);
const body = pageResult.sections
?.map((section) => section.body)
.join('\n');
return {
type: 'text',
text: [
`Title: ${pageResult.title}`,
`Link: ${pageURL}`,
body ? `Content: ${body}` : '',
]
.filter(Boolean)
.join('\n'),
};
});
}),
};
}
);
},
{},
{
basePath: context.linker.toPathInSite('~gitbook/'),
streamableHttpEndpoint: '/mcp',
maxDuration: 60,
verboseLogs: true,
disableSse: true,
}
);
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
);
requestURL.search = nextRequest.nextUrl.search;
const request = new Request(requestURL, nextRequest);
return mcpHandler(request);
}
export { handler as GET, handler as POST };
@@ -0,0 +1,183 @@
import {
type RevisionPage,
type RevisionPageDocument,
type RevisionPageGroup,
SiteVisibility,
} from '@gitbook/api';
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { isPageIndexable } from '@/lib/seo';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLocalizedTitle,
listAllSiteSpaces,
} from '@/lib/sites';
interface Breadcrumb {
label: string;
icon?: string;
emoji?: string;
}
interface RawIndexPage {
id: string;
title: string;
pathname: string;
siteSpaceId: string;
lang?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: Breadcrumb[];
}
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
interface IndexPageEntry {
page: RevisionPageDocument;
ancestors: AncestorPage[];
}
/**
* Walk the page tree and return all indexable document pages together with
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
*/
function getIndexablePagesWithAncestors(
rootPages: RevisionPage[],
ancestors: AncestorPage[] = []
): IndexPageEntry[] {
const results: IndexPageEntry[] = [];
for (const page of rootPages) {
if (page.type === 'link' || page.type === 'computed') continue;
if (page.hidden || !isPageIndexable([], page)) continue;
if (page.type === 'document') {
results.push({ page, ancestors });
// Recurse into children with this document as an ancestor
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
} else if (page.type === 'group') {
// Groups themselves are not documents — push them only as ancestors
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
}
}
return results;
}
export const revalidate = 86400; // 1 day in seconds
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, structure } = context;
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
const revisions = await Promise.all(
visibleSpaces.map((ss) =>
throwIfDataError(
dataFetcher.getRevision({
spaceId: ss.space.id,
revisionId: ss.space.revision,
})
)
)
);
const seen = new Set<string>();
const pages: RawIndexPage[] = [];
for (let i = 0; i < visibleSpaces.length; i++) {
const siteSpace = visibleSpaces[i]!;
const revision = revisions[i]!;
const forkedLinker = linker.withOtherSiteSpace({
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
});
const lang = siteSpace.space.language ?? undefined;
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
const cacheKey = `${siteSpace.id}:${page.id}`;
if (seen.has(cacheKey)) continue;
seen.add(cacheKey);
const breadcrumbs: Breadcrumb[] = [
siteSectionGroup
? {
label: getLocalizedTitle(siteSectionGroup, lang),
icon: siteSectionGroup.icon ?? undefined,
}
: undefined,
siteSection
? {
label: getLocalizedTitle(siteSection, lang),
icon: siteSection.icon ?? undefined,
}
: undefined,
...ancestors.map((a) => ({
label: a.title,
icon: a.icon ?? undefined,
emoji: a.emoji ?? undefined,
})),
].filter((c) => c !== undefined);
pages.push({
id: page.id,
title: page.title,
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
siteSpaceId: siteSpace.id,
lang,
icon: page.icon ?? undefined,
emoji: page.emoji ?? undefined,
description: page.description ?? undefined,
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
});
}
}
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
const shouldCacheOnClient =
context.site.visibility === SiteVisibility.Public ||
context.site.visibility === SiteVisibility.Unlisted;
return new Response(
JSON.stringify({
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
version: 1,
pages,
}),
{
headers: {
'Content-Type': 'application/json',
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
'Cache-Control': shouldCacheOnClient
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
: 'no-store',
},
}
);
}
+1 -1
View File
@@ -119,7 +119,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
try {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
@@ -0,0 +1,28 @@
import { type NextRequest, NextResponse } from 'next/server';
import { getIconSymbol } from '@/lib/icons/symbols';
import { getIconSymbolId } from '@gitbook/icons';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ style: string; icon: string }> }
) {
const { style, icon } = await params;
const symbol = await getIconSymbol(style, icon, getIconSymbolId(style, icon));
if (!symbol) {
return NextResponse.json(
{
error: 'Symbol not found',
},
{ status: 404 }
);
}
return new NextResponse(symbol.document, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'cache-control': 'public, max-age=31536000, immutable',
},
});
}
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
body.tags.forEach((tag) => {
logger.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
});
return NextResponse.json({
@@ -1,6 +1,7 @@
'use client';
import { Button } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { z } from 'zod';
import { AIToolContainer } from './common';
import { type GetAIControlProps, createAIControl } from './helpers';
@@ -29,6 +30,7 @@ export const ConfirmControlDef = createAIControl({
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
const { label, icon, onSubmit } = props;
const language = useLanguage();
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<Button
@@ -36,17 +38,18 @@ function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
onClick={() => {
onSubmit({ result: 'cancelled' });
}}
variant="secondary"
truncate={false}
variant="blank"
icon="xmark"
label="Cancel"
label={tString(language, 'cancel')}
/>
<Button
data-testid="ai-chat-tool-confirm-accept"
onClick={() => {
onSubmit({ result: 'confirmed' });
}}
className="flex-1"
variant="primary"
truncate={false}
icon={icon}
label={label}
/>
@@ -0,0 +1,229 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon, IconStyle } from '@gitbook/icons';
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 MultiChoiceControlOutputSchema = z.object({
ids: z.array(z.string()).describe('The identifiers of the options selected by the user.'),
labels: z.array(z.string()).describe('The labels of the options selected by the user.'),
input: z
.string()
.optional()
.describe(
'The custom text entered by the user when "Other" is enabled and selected among the choices.'
),
});
export const MultiChoiceControlDef = createAIControl({
name: 'multi-choice',
exposeAsTool: true,
description:
'Use this control whenever you need the user to choose one or more options from a predefined list. Never add an "Other" option to the `options` array, use the `allowOther` property instead.',
inputSchema: z.object({
prompt: z
.string()
.describe(
'Provide the question or instruction that tells the user what choices 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 may select one or more.'
),
allowOther: z
.boolean()
.optional()
.describe(
'Set to true to let the user select an "Other" option and enter a custom text response.'
),
}),
outputSchema: MultiChoiceControlOutputSchema,
render: (props) => {
return <MultiChoiceControl {...props} />;
},
});
function MultiChoiceControl(props: GetAIControlProps<typeof MultiChoiceControlDef>) {
const { prompt, options, allowOther, onSubmit } = props;
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [otherInput, setOtherInput] = React.useState('');
const orderedOptionIds = [
...options.map((option) => option.id),
...(allowOther ? [OTHER_OPTION_ID] : []),
];
const language = useLanguage();
const toggleOption = (id: string) => {
setSelectedIds((current) =>
current.includes(id)
? current.filter((currentId) => currentId !== id)
: orderedOptionIds.filter(
(optionId) => optionId === id || current.includes(optionId)
)
);
};
const hasOtherSelected = selectedIds.includes(OTHER_OPTION_ID);
const canSubmit = selectedIds.length > 0 && (!hasOtherSelected || otherInput.trim().length > 0);
return (
<AIToolContainer className="flex w-full flex-col gap-2">
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
{options.map((option) => {
const isSelected = selectedIds.includes(option.id);
return (
<button
key={option.id}
type="button"
data-testid={`ai-chat-tool-multi-choice-option-${option.id}`}
onClick={() => {
toggleOption(option.id);
}}
className={tcls(
'text-left transition-colors',
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
isSelected
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
)}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
isSelected
? 'border-primary-original bg-primary-original text-contrast-primary-original'
: 'border-tint-subtle'
)}
>
{isSelected ? (
<Icon
icon="check"
iconStyle={IconStyle.Solid}
className="size-3"
/>
) : null}
</span>
<span className="min-w-0">
<p className="font-medium text-sm">{option.label}</p>
{option.description ? (
<p className="mt-0.5 text-tint-subtle text-xs">
{option.description}
</p>
) : null}
</span>
</div>
</button>
);
})}
{allowOther ? (
<button
type="button"
data-testid="ai-chat-tool-multi-choice-option-other"
tabIndex={-1}
onClick={() => {
toggleOption(OTHER_OPTION_ID);
}}
>
<Input
label={tString(language, 'form_other_prompt')}
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-multi-choice-other-input"
placeholder={tString(
language,
hasOtherSelected ? 'form_other_prompt' : 'form_other_field'
)}
className={tcls(
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
hasOtherSelected
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
)}
sizing="small"
onClick={(event) => {
event.stopPropagation();
if (!hasOtherSelected) {
toggleOption(OTHER_OPTION_ID);
}
}}
leading={
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
hasOtherSelected
? 'border-primary-original bg-primary-original text-contrast-primary-original'
: 'border-tint-subtle'
)}
>
{hasOtherSelected ? (
<Icon
icon="check"
iconStyle={IconStyle.Solid}
className="size-3"
/>
) : null}
</span>
}
/>
</button>
) : null}
</ScrollContainer>
<Button
data-testid="ai-chat-tool-multi-choice-submit"
variant="primary"
label={tString(language, 'submit')}
disabled={!canSubmit}
onClick={() => {
if (!canSubmit) {
return;
}
onSubmit({
ids: selectedIds,
labels: selectedIds.map((id) =>
id === OTHER_OPTION_ID
? 'Other'
: options.find((option) => option.id === id)?.label || ''
),
input: hasOtherSelected ? otherInput.trim() : undefined,
});
}}
/>
</AIToolContainer>
);
}
@@ -1,6 +1,8 @@
'use client';
import { Button, Input } from '@/components/primitives';
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import * as React from 'react';
import { z } from 'zod';
@@ -11,6 +13,7 @@ const OTHER_OPTION_ID = '$other';
export const SingleChoiceControlOutputSchema = z.object({
id: z.string().describe('The identifier of the option selected by the user.'),
label: z.string().describe('The label of the option selected by the user.'),
input: z
.string()
.optional()
@@ -21,7 +24,7 @@ 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.',
'Use this control whenever you need the user to choose exactly one option from a predefined list. Important: NEVER write an "Other" choice yourself to the `options` array, pass the `allowOther` property as `true` to add it automatically instead.',
inputSchema: z.object({
prompt: z
.string()
@@ -44,7 +47,7 @@ export const SingleChoiceControlDef = createAIControl({
.string()
.optional()
.describe(
'Optionally provide supporting details to help the user understand this option.'
'Optionally provide supporting details to help the user understand this option. Keep it concise and do not repeat the label.'
),
})
.describe('Define one selectable option the user can pick.')
@@ -70,14 +73,16 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [otherInput, setOtherInput] = React.useState('');
const language = useLanguage();
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>
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
{options.map((option) => {
const isSelected = selectedId === option.id;
return (
@@ -89,17 +94,18 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
setSelectedId(option.id);
}}
className={tcls(
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
'text-left transition-colors',
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
isSelected
? 'border-primary-original bg-primary-subtle text-tint-strong'
: 'border-tint bg-tint-base hover:bg-tint-subtle'
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
)}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span
aria-hidden
className={tcls(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
isSelected
? 'border-primary-original'
: 'border-tint-subtle'
@@ -115,7 +121,7 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
<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">
<p className="mt-0.5 text-tint-subtle text-xs">
{option.description}
</p>
) : null}
@@ -129,58 +135,58 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
<button
type="button"
data-testid="ai-chat-tool-single-choice-option-other"
tabIndex={-1} // The input is already focusable, so prevent focus on the wrapper button
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'
)}
>
<Input
label={tString(language, 'form_other_prompt')}
value={otherInput}
onValueChange={setOtherInput}
data-testid="ai-chat-tool-single-choice-other-input"
placeholder={tString(
language,
selectedId === OTHER_OPTION_ID
? 'form_other_prompt'
: 'form_other_field'
)}
className={tcls(
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
selectedId === OTHER_OPTION_ID
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
)}
sizing="small"
leading={
<span
aria-hidden
className={tcls(
'size-2.5 rounded-full transition-colors',
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
selectedId === OTHER_OPTION_ID
? 'bg-primary-original'
: 'bg-transparent'
? 'border-primary-original'
: 'border-tint-subtle'
)}
/>
</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"
>
<span
className={tcls(
'size-2.5 rounded-full transition-colors',
selectedId === OTHER_OPTION_ID
? 'bg-primary-original'
: 'bg-transparent'
)}
/>
) : null}
</div>
</div>
</span>
}
/>
</button>
) : null}
</div>
</ScrollContainer>
<Button
data-testid="ai-chat-tool-single-choice-submit"
variant="primary"
label="Submit answer"
label={tString(language, 'submit')}
disabled={!canSubmit}
onClick={() => {
if (!canSubmit || !selectedId) {
@@ -190,12 +196,16 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
if (selectedId === OTHER_OPTION_ID) {
onSubmit({
id: OTHER_OPTION_ID,
label: 'Other',
input: otherInput.trim(),
});
return;
}
onSubmit({ id: selectedId });
onSubmit({
id: selectedId,
label: options.find((option) => option.id === selectedId)?.label || '',
});
}}
/>
</AIToolContainer>
@@ -6,10 +6,10 @@ export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
<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',
'min-h-0 shrink grow-0 animate-blur-in circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base p-2',
props.className
)}
style={{ animationDelay: '0.5s', ...props.style }}
style={{ animationDelay: '0.3s', ...props.style }}
/>
);
}
@@ -1,3 +1,5 @@
import { tString } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations/types';
import type {
AIStreamResponseToolCallPending,
AIToolCallResult,
@@ -16,6 +18,7 @@ type AIControlDefinition<
createControl: (args: {
context: AIUIToolContext;
input: Input;
language: TranslationLanguage;
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
}) => AIControl<Name, Input, Output>;
exposeAsTool: boolean;
@@ -55,15 +58,15 @@ export function createAIControl<
name: `ui--${def.name}`,
description: def.description,
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
createControl: ({ context, input, send }) => {
createControl: ({ context, input, language, send }) => {
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
...input,
onSubmit: async (output) => {
await send({
output,
summary: {
icon: 'check',
text: 'Submitted',
icon: 'comment-check',
text: tString(language, 'ai_control_submitted_answer'),
},
});
},
@@ -1,7 +1,8 @@
import { ConfirmControlDef } from './ConfirmControl';
import { MultiChoiceControlDef } from './MultiChoiceControl';
import { SingleChoiceControlDef } from './SingleChoiceControl';
const CONTROLS = [SingleChoiceControlDef, ConfirmControlDef];
const CONTROLS = [SingleChoiceControlDef, MultiChoiceControlDef, ConfirmControlDef];
export type AnyAIControlTool = (typeof CONTROLS)[number];
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
@@ -1,6 +1,7 @@
import { CollapsibleContent } from '@/components/primitives';
import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import type { AIMessage } from '@gitbook/api';
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary';
import type { RenderAIMessageOptions } from './types';
@@ -17,31 +18,41 @@ export function AIMessageView(
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return message.steps.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex flex-col space-y-2">
{message.steps.map((step, index) => {
const hasContent = Boolean(step.content && step.content.nodes.length > 0);
const Tag =
step.phase === AIMessageStepPhase.Commentary ? CollapsibleContent : 'div';
return (
<div
<Tag
key={index}
className={tcls(
'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
'flex flex-col space-y-2 border-tint-subtle',
hasContent ? 'has-content' : ''
)}
>
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
/>
{step.content ? (
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style={tcls(
'ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden',
step.phase === AIMessageStepPhase.Commentary
? 'text-tint group-data-[disabled]/collapsible:text-inherit'
: ''
)}
/>
) : null}
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null}
</div>
</Tag>
);
})}
</div>
@@ -39,9 +39,7 @@ export async function streamRenderAIMessage(
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
// @ts-expect-error
message.steps[stepIndex] = { ...message.steps[stepIndex] };
// @ts-expect-error
callback(message.steps[stepIndex]);
} else {
message.steps = [
@@ -67,6 +65,16 @@ export async function streamRenderAIMessage(
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* A new step started (phase update).
*/
case 'response_step_start': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.phase = event.phase;
});
break;
}
/**
* The agent is processing a tool call in a new message.
*/
@@ -1,5 +1,4 @@
'use server';
import { getEmbeddableLinker } from '@/lib/embeddable';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -9,6 +8,7 @@ import {
AIModel,
type AIToolCallResult,
type AIToolDefinition,
SiteCoreChannelType,
type SiteInsightsSession,
} from '@gitbook/api';
import { streamRenderAIMessage } from './api';
@@ -35,10 +35,9 @@ export async function* streamAIChatResponse({
options?: RenderAIMessageOptions;
}) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
let context = await getServerActionBaseContext();
if (options?.asEmbeddable) {
context = { ...context, linker: getEmbeddableLinker(context.linker) };
}
const context = await getServerActionBaseContext({
isEmbeddable: options?.asEmbeddable,
});
const siteURLData = await getSiteURLDataFromMiddleware();
@@ -61,6 +60,11 @@ export async function* streamAIChatResponse({
toolCall,
tools,
session,
channel: {
type: options?.asEmbeddable
? SiteCoreChannelType.Embed
: SiteCoreChannelType.Site,
},
}
);
+112 -14
View File
@@ -6,13 +6,14 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
AIMessageStepPhase,
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import * as React from 'react';
import { getVisitor, useTrackEvent } from '../Insights';
import { getSession } from '../Insights/sessions';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
@@ -24,8 +25,25 @@ export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
activity?: AIChatMessageActivity;
};
export type AIChatMessageActivity = {
currentPhase?: AIMessageStepPhase;
toolCount: number;
hasCommentary: boolean;
hasFinalAnswer: boolean;
};
export type AIChatStatus =
| 'default'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
export type AIChatState = {
/**
* If true, the chat is open.
@@ -208,6 +226,7 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
@@ -224,7 +243,7 @@ export function AIChatProvider(props: {
try {
const result = await toolDef.execute(event.toolCall.input);
streamResponse({
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
@@ -233,7 +252,7 @@ export function AIChatProvider(props: {
},
});
} catch (error) {
streamResponse({
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
@@ -257,10 +276,7 @@ export function AIChatProvider(props: {
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
},
session: await getInsightsSession(),
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
@@ -322,6 +338,7 @@ export function AIChatProvider(props: {
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
language,
send: async (result) => {
await streamResponse({
toolCall: {
@@ -350,6 +367,7 @@ export function AIChatProvider(props: {
label: confirmation.label,
icon: confirmation.icon,
},
language,
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
@@ -398,6 +416,11 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
@@ -406,13 +429,13 @@ export function AIChatProvider(props: {
// Execute the tool call if it doesn't require confirmation
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
loading: false,
error: false,
}));
}
globalState.setState((state) => ({
...state,
loading: false,
error: false,
}));
} catch (error) {
console.error('Error streaming AI response', error);
globalState.setState((state) => ({
@@ -558,3 +581,78 @@ export function useAIChatController(): AIChatController {
}
return controller;
}
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
if (chat.error) {
return 'error';
}
if (chat.control) {
return 'confirm';
}
if (chat.loading) {
const latestMessage = getLatestAssistantMessage(chat.messages);
const phase = latestMessage?.activity?.currentPhase;
switch (phase) {
case AIMessageStepPhase.Commentary:
return 'exploring';
case AIMessageStepPhase.FinalAnswer:
return 'working';
default:
return 'thinking';
}
}
if (chat.messages.length > 0) {
return 'done';
}
return 'default';
}
function getLatestAssistantMessage(messages: AIChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role === AIMessageRole.Assistant) {
return message;
}
}
return null;
}
function updateAIChatMessageActivity(
activity: AIChatMessageActivity,
event: AIStreamResponse
): AIChatMessageActivity {
switch (event.type) {
case 'response_step_start': {
return {
...activity,
currentPhase: event.phase,
hasCommentary:
activity.hasCommentary || event.phase === AIMessageStepPhase.Commentary,
hasFinalAnswer:
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
};
}
case 'response_tool_call': {
return {
...activity,
toolCount: activity.toolCount + 1,
};
}
default:
return activity;
}
}
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
return {
currentPhase: undefined,
toolCount: 0,
hasCommentary: false,
hasFinalAnswer: false,
};
}
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
getAIChatStatus,
useAI,
useAIChatController,
useAIChatState,
@@ -107,7 +108,7 @@ export function AIChat() {
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody className="not-embed:px-4">
<EmbeddableFrameBody className="not-embed:px-0">
<AIChatBody
chatController={chatController}
chat={chat}
@@ -130,24 +131,13 @@ export function AIChatDynamicIcon(props: {
}) {
const { trademark, className } = props;
const chat = useAIChatState();
const status = getAIChatStatus(chat);
return (
<AIChatIcon
className={tcls('size-5 text-tint', className)}
trademark={trademark}
state={
chat.error
? 'error'
: chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.control
? 'confirm'
: 'done'
: 'default'
}
state={status}
/>
);
}
@@ -160,35 +150,45 @@ export function AIChatSubtitle(props: {
}) {
const { chat } = props;
const language = useLanguage();
const status = getAIChatStatus(chat);
const subtitleKey =
status === 'thinking'
? 'ai_chat_thinking'
: status === 'exploring'
? 'ai_chat_exploring'
: status === 'working'
? 'ai_chat_working'
: status === 'confirm'
? 'ai_chat_waiting'
: null;
return (
<EmbeddableFrameSubtitle
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
className={tcls('relative', subtitleKey ? 'h-3 opacity-11' : 'h-0 opacity-0')}
>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-slow'
: 'hidden'
: 'animate-blur-out-slow'
)}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-slow'
: 'animate-blur-in-slow'
: 'hidden'
)}
>
{t(language, 'ai_chat_thinking')}
</span>
{(
[
['thinking', 'ai_chat_thinking'],
['exploring', 'ai_chat_exploring'],
['working', 'ai_chat_working'],
['confirm', 'ai_chat_waiting'],
] as const
).map(([candidateStatus, key]) => (
<span
key={candidateStatus}
className={tcls(
'absolute left-0',
status === candidateStatus
? 'animate-blur-in-display-slow'
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay: status === candidateStatus ? '.3s' : undefined,
}}
>
{subtitleKey ? t(language, key) : null}
</span>
))}
</EmbeddableFrameSubtitle>
);
}
@@ -226,7 +226,7 @@ export function AIChatBody(props: {
<>
<ScrollContainer
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4"
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
@@ -274,21 +274,18 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex min-h-0 flex-col gap-2 pb-4">
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
{chat.control ? (
<AIChatControl control={chat.control} />
) : (
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
)}
{chat.control ? <AIChatControl control={chat.control} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
/>
</div>
</>
);
@@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button to open/close the AI chat.
@@ -4,7 +4,15 @@ import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
size?: number;
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
state?:
| 'default'
| 'intro'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
trademark?: boolean;
}
@@ -25,6 +33,7 @@ export function AIChatIcon({
animation: {
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)',
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
@@ -43,7 +52,7 @@ export function AIChatIcon({
viewBox="0 0 16 16"
preserveAspectRatio="xMaxYMid meet"
className={className}
aria-busy={state === 'thinking'}
aria-busy={['thinking', 'exploring', 'working'].includes(state)}
overflow="visible"
{...props}
fill="none"
@@ -63,6 +72,7 @@ export function AIChatIcon({
animation: {
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)',
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
exploring: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
@@ -124,6 +134,7 @@ export function AIChatIcon({
animation: {
intro: '',
thinking: '',
exploring: '',
working: '',
done: '',
confirm: 'bounceSmall 1s infinite both',
@@ -146,6 +157,7 @@ export function AIChatIcon({
animation: {
intro: 'fadeIn 2s forwards',
thinking: '',
exploring: '',
working: '',
done: '',
confirm: '',
@@ -186,6 +198,7 @@ export function AIChatIcon({
animation: {
intro: 'pathEnter 1.5s both ease-out',
thinking: 'pathLoading 2s infinite both',
exploring: 'pathLoading 2s infinite both',
working: 'pathLoading 2s infinite both',
done: 'pathEnter 1s forwards ease',
confirm: '',
@@ -201,7 +214,15 @@ export function AIChatIcon({
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
state?:
| 'default'
| 'intro'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
}
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
@@ -213,6 +234,7 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
intro: 'fadeIn 1s both, orbit 1s cubic-bezier(0.16,1,0.3,1)',
thinking:
'fadeIn 1s both, orbit 1s orbit 2s 1s infinite forwards linear ease-out,',
exploring: '',
working: '',
done: '',
confirm: '',
@@ -234,6 +256,7 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
animation: {
intro: 'spin 2s .5s forwards cubic-bezier(0.16,1,0.3,1)',
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
@@ -1,4 +1,5 @@
import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -58,7 +59,10 @@ export function AIChatInput(props: {
size: 'small',
label: tString(language, 'send'),
}}
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
className={tcls(
chat.control ? 'animate-blur-out-display' : 'animate-blur-in-slow',
'bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base'
)}
rows={1}
maxLength={2048}
keyboardShortcut={
@@ -69,7 +73,7 @@ export function AIChatInput(props: {
}
: undefined
}
disabled={disabled || loading}
disabled={disabled || loading || chat.control !== null}
aria-busy={loading}
ref={inputRef}
trailing={
@@ -1,8 +1,16 @@
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type { AIChatController, AIChatState } from '../AI';
import {
type AIChatController,
type AIChatMessage,
type AIChatState,
getAIChatStatus,
} from '../AI';
import { ToggleChevron } from '../primitives';
import { Button } from '../primitives/Button';
import { Collapsible, CollapsibleTrigger } from '../primitives/Collapsible';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -11,10 +19,11 @@ export function AIChatMessages(props: {
chatController: AIChatController;
}) {
const { chat, chatController } = props;
const status = getAIChatStatus(chat);
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type Message = (typeof chat.messages)[0];
type MessageGroup = { message: Message; originalIndex: number };
type MessageGroup = { message: AIChatMessage; originalIndex: number };
const messageGroups: Array<Array<MessageGroup>> = [];
let currentGroup: Array<MessageGroup> = [];
@@ -36,6 +45,8 @@ export function AIChatMessages(props: {
messageGroups.push(currentGroup);
}
const language = useLanguage();
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
return (
@@ -52,8 +63,16 @@ export function AIChatMessages(props: {
>
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
const toolCount = message.activity?.toolCount ?? 0;
const hasCommentary =
(message.activity?.hasCommentary ?? false) || toolCount > 0;
const hasFinalAnswer =
(message.activity?.hasFinalAnswer ?? false) || !isLastMessage;
return (
<div
<Collapsible
open={!hasFinalAnswer}
disabled={!hasFinalAnswer}
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
@@ -62,25 +81,60 @@ export function AIChatMessages(props: {
}
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-6',
'flex flex-col gap-2',
'break-words',
'group/message',
'animate-blur-in-slow',
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'
? 'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong',
isLastMessage && message.role === AIMessageRole.Assistant
? 'grow'
: ''
)}
style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{message.role === AIMessageRole.Assistant &&
hasCommentary &&
hasFinalAnswer ? (
<CollapsibleTrigger asChild>
<Button
variant="blank"
size="small"
label={tString(language, 'ai_chat_view_activity')}
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
>
<div className="flex items-center gap-2">
{toolCount > 0
? t(
language,
'ai_chat_explored_with',
tString(
language,
toolCount === 1
? 'tool_count'
: 'tool_count_plural',
toolCount.toString()
)
)
: t(language, 'ai_chat_explored')}
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
{message.content}
{isLastMessage && message.role === AIMessageRole.Assistant ? (
<div
className={tcls(
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
chat.loading ? '' : 'hidden opacity-0'
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
showLoadingShim
? 'max-h-48 opacity-11'
: 'pointer-events-none max-h-0 opacity-0'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
@@ -94,11 +148,11 @@ export function AIChatMessages(props: {
!chat.error &&
chat.query &&
chat.responseId &&
chat.control ? (
!chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
className="-ml-1.5 -mt-4 mb-2"
/>
) : null}
<AIChatFollowupSuggestions
@@ -107,7 +161,7 @@ export function AIChatMessages(props: {
/>
</>
) : null}
</div>
</Collapsible>
);
})}
</div>
@@ -20,7 +20,7 @@ export default function AIChatSuggestedQuestions(props: {
return (
<div
className="flex max-w-full max-w-full flex-col items-start gap-2 self-start"
className="flex max-w-full flex-col items-start gap-2 self-start"
data-testid="ai-chat-suggested-questions"
>
{suggestions.map((question, index) => (
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
}
return (
<div className="flex grow flex-col">
<div className="mt-auto flex grow flex-col">
<div
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
data-testid="ai-chat-followup-suggestions"
@@ -30,7 +30,7 @@ export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
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"
className="-translate-x-1/2 pointer-events-none absolute bottom-[calc(100%+2px)] left-1/2 flex items-center gap-1.5 rounded-lg border border-[#eaeaea] border-solid bg-white py-1 pr-1 pl-2"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
This toolbar appears only when signed in to GitBook.
@@ -26,6 +26,7 @@ export async function Announcement(props: {
return (
<AnnouncementBanner
announcement={customization.announcement}
locale={context.locale}
contentRef={resolvedContentRef}
/>
);
@@ -3,8 +3,13 @@
import { tString, useLanguage } from '@/intl/client';
import { setLocalStorageItem } from '@/lib/browser';
import type { ResolvedContentRef } from '@/lib/references';
import { getLocalizedMessage, getLocalizedTitle } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
import {
type CustomizationAnnouncement,
SiteInsightsLinkPosition,
type TranslationLanguage,
} from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { CONTAINER_STYLE } from '../layout';
import { Button, Link } from '../primitives';
@@ -16,11 +21,14 @@ import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
*/
export function AnnouncementBanner(props: {
announcement: CustomizationAnnouncement;
locale: TranslationLanguage | undefined;
contentRef: ResolvedContentRef | null;
}) {
const { announcement, contentRef } = props;
const { announcement, locale, contentRef } = props;
const language = useLanguage();
const message = getLocalizedMessage(announcement, locale);
const linkTitle = announcement.link ? getLocalizedTitle(announcement.link, locale) : undefined;
const hasLink = announcement.link && contentRef?.href;
const closeable = announcement.style !== 'danger';
@@ -61,7 +69,7 @@ export function AnnouncementBanner(props: {
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
/>
<div>
{announcement.message}
{message}
{hasLink ? (
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? (
@@ -69,9 +77,7 @@ export function AnnouncementBanner(props: {
{contentRef?.icon}
</span>
) : null}
{announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span>
)}
{linkTitle ? <span className="mr-1">{linkTitle}</span> : null}
<Icon
icon={
announcement.link?.to.kind === 'url'
@@ -5,7 +5,6 @@ import {
SkeletonHeading,
SkeletonImage,
SkeletonParagraph,
SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind';
@@ -142,7 +141,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'file':
return <SkeletonHeading id={id} style={style} />;
case 'paragraph':
return <SkeletonSmall id={id} style={style} />;
return <SkeletonParagraph id={id} className={style} />;
case 'list-ordered':
case 'list-unordered':
case 'list-tasks':
@@ -153,7 +152,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'tabs':
case 'stepper-step':
case 'if':
return <SkeletonParagraph id={id} style={style} />;
return <SkeletonParagraph id={id} className={style} />;
case 'expandable':
case 'table':
case 'swagger':
@@ -173,7 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'drawing':
return <SkeletonImage id={id} style={style} />;
case 'updates':
return <SkeletonUpdate id={id} style={style} />;
return <SkeletonUpdate id={id} className={style} />;
case 'image':
case 'code-line':
case 'tabs-item':
@@ -2,6 +2,7 @@ import type { DocumentBlock, JSONDocument } from '@gitbook/api';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
import { Block } from './Block';
import type { DocumentContextProps } from './DocumentView';
import { isBlockOffscreen } from './utils';
@@ -63,7 +64,7 @@ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
'columns',
'code',
'content-ref',
'hint',
'divider',
];
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
@@ -89,12 +90,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
'decoration-primary/6',
FULL_WIDTH_BLOCKS.includes(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -193,10 +193,10 @@ function CodeBlockExpandable(props: {
<Button
icon={<ToggleChevron open={isExpanded} />}
size="xsmall"
variant="blank"
variant="secondary"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
className="pointer-events-auto z-1 my-2 bg-tint! text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
aria-expanded={isExpanded}
aria-controls={controls}
>
@@ -41,14 +41,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
ref={ref}
aria-busy={ariaBusy}
className={tcls(
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
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]">
<div className="flex items-center justify-start gap-2 text-sm">
{title ? (
<div
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
@@ -63,43 +63,45 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
) : null}
</div>
<CopyCodeButton
codeId={codeId}
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base 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}
<div className="relative">
<CopyCodeButton
codeId={codeId}
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
/>
<pre
className={tcls(
'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%'
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
'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,
}}
>
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
</code>
</pre>
<code
id={codeId}
className={tcls(
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
)}
>
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
</code>
</pre>
</div>
</div>
);
});
@@ -3,63 +3,117 @@
import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import type { RenderResult } from 'mermaid';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';
/**
* Used to render a Mermaid diagram from a CodeBlock.
*/
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, style } = props;
const { block, mode, style } = props;
const source = getPlainCodeBlock(block);
const rootRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
const shouldRender = mode === 'print' || hasBeenInViewport;
useEffect(() => {
if (!shouldRender) {
return;
}
const container = diagramRef.current;
if (!container) {
const wrapper = wrapperRef.current;
if (!container || !wrapper) {
return;
}
let cancelled = false;
let cleanupPanZoom: (() => void) | undefined;
setError(false);
setIsLoading(true);
setPanZoom(null);
container.innerHTML = '';
renderMermaidDiagram({
container,
source,
id,
darkMode,
})
.catch(() => {
if (!cancelled) {
setError(true);
const cancelScheduledRender = scheduleMermaidWork(() => {
enqueueMermaidRender(async () => {
if (cancelled) {
return null;
}
return renderMermaidDiagram({
source,
id,
darkMode,
});
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
.then((result) => {
if (!result || cancelled) {
return;
}
container.innerHTML = result.svg;
if (container.querySelector('svg')) {
result.bindFunctions?.(container);
}
cleanupPanZoom = initPanzoom({
container,
wrapper,
onInit: setPanZoom,
});
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
});
return () => {
cancelled = true;
cancelScheduledRender();
cleanupPanZoom?.();
setPanZoom(null);
};
}, [source, id, darkMode]);
}, [source, id, darkMode, shouldRender]);
if (error) {
return <ClientCodeBlock {...props} />;
}
return (
<div className={tcls('relative', style)} contentEditable={false}>
<div className={isLoading ? 'invisible absolute inset-x-0' : undefined}>
<div
ref={rootRef}
className={tcls('group/mermaid relative', style)}
contentEditable={false}
>
<div
ref={wrapperRef}
className={
isLoading
? 'invisible absolute inset-x-0 overflow-hidden'
: 'cursor-grab overflow-hidden active:cursor-grabbing'
}
>
<div
ref={diagramRef}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
@@ -70,23 +124,18 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
<Loading className="h-8 w-8" />
</div>
) : null}
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : 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]);
}): Promise<RenderResult> {
const { source, id, darkMode } = args;
const { mermaid } = await loadMermaid();
mermaid.initialize({
startOnLoad: false,
@@ -95,9 +144,141 @@ async function renderMermaidDiagram(args: {
theme: darkMode ? 'dark' : undefined,
});
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
container.innerHTML = svg;
bindFunctions?.(container);
const renderContainer = createMermaidRenderContainer();
try {
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
} finally {
renderContainer.remove();
}
}
/**
* Mermaid measures labels while rendering, so the temporary render target must be
* connected to the document. Keep it fixed and contained so those layout reads do
* not walk the visible document flow.
*/
function createMermaidRenderContainer() {
const container = document.createElement('div');
container.setAttribute('aria-hidden', 'true');
Object.assign(container.style, {
contain: 'strict',
height: '100vh',
isolation: 'isolate',
left: '0',
overflow: 'hidden',
pointerEvents: 'none',
position: 'fixed',
top: '0',
visibility: 'hidden',
width: '100vw',
zIndex: '-1',
});
document.body.appendChild(container);
return container;
}
let mermaidLoadPromise: Promise<{
mermaid: typeof import('mermaid')['default'];
}> | null = null;
async function loadMermaid() {
if (!mermaidLoadPromise) {
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
await mermaid.registerExternalDiagrams([zenuml]);
return { mermaid };
})
.catch((error) => {
mermaidLoadPromise = null;
throw error;
});
}
return mermaidLoadPromise;
}
let mermaidRenderQueue = Promise.resolve();
function enqueueMermaidRender<T>(task: () => Promise<T>) {
const result = mermaidRenderQueue.then(task, task);
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
return result;
}
function waitForNextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function scheduleMermaidWork(callback: () => void) {
const win = window as WindowWithIdleCallback;
let cancelled = false;
if (win.requestIdleCallback && win.cancelIdleCallback) {
const handle = win.requestIdleCallback(
() => {
if (!cancelled) {
callback();
}
},
{ timeout: 1500 }
);
return () => {
cancelled = true;
win.cancelIdleCallback?.(handle);
};
}
const handle = window.setTimeout(() => {
if (!cancelled) {
callback();
}
});
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}
type WindowWithIdleCallback = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (handle: number) => void;
};
/**
* Initialize panzoom on the diagram container.
*/
function initPanzoom(args: {
container: HTMLElement;
wrapper: HTMLElement;
onInit: (instance: ReturnType<typeof Panzoom> | null) => void;
}): () => void {
const { container, wrapper, onInit } = args;
const instance = Panzoom(container, {
maxScale: 5,
minScale: 0.5,
cursor: 'grab',
panOnlyWhenZoomed: true,
});
onInit(instance);
wrapper.addEventListener('wheel', instance.zoomWithWheel, { passive: false });
return () => {
wrapper.removeEventListener('wheel', instance.zoomWithWheel);
instance.destroy();
onInit(null);
};
}
function useSafeId() {
@@ -0,0 +1,52 @@
'use client';
import type { PanzoomObject } from '@panzoom/panzoom';
import { Button } from '@/components/primitives';
const PAN_STEP = 50;
/**
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
const btnProps = {
variant: 'secondary' as const,
size: 'xsmall' as const,
iconOnly: true,
};
return (
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
{/* Row 1: empty, pan up, zoom in */}
<div />
<Button
{...btnProps}
icon="chevron-up"
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
{/* Row 2: pan left, reset, pan right */}
<Button
{...btnProps}
icon="chevron-left"
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
<Button
{...btnProps}
icon="chevron-right"
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button
{...btnProps}
icon="chevron-down"
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
</div>
);
}
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props;
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
return <hr className={tcls(style, 'border-tint-subtle')} />;
}
@@ -93,7 +93,7 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
style={[
'mx-auto w-full decoration-primary/6',
block.data && 'fullWidth' in block.data && block.data.fullWidth
? 'max-w-screen-xl'
? 'max-w-6xl'
: 'max-w-3xl',
blockStyle,
]}
@@ -56,12 +56,13 @@ export function Details(props: {
'border-tint-subtle',
//all
'mt-0!',
'mb-0',
//select first child
'first:mt-5!',
'first:rounded-t-lg',
//select first in group
'[:not(&)_+&]:mt-5!',
'[:not(&)_+&]:rounded-t-lg',
'[:not(&)_+_&]:mt-5!',
'[:not(&)_+_&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
@@ -1,13 +1,16 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import type React from 'react';
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
import { Button, type ButtonProps } from '../primitives';
import type { InlineProps } from './Inline';
import { InlineActionButton } from './InlineActionButton';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline } = props;
const { inline, context } = props;
const buttonProps: ButtonProps = {
label: inline.data.label,
@@ -17,7 +20,8 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
};
const ButtonImplementation = () => {
if ('action' in inline.data && 'query' in inline.data.action) {
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
return (
<InlineActionButton
action={inline.data.action.action}
@@ -57,21 +61,27 @@ export async function InlineLinkButton(
const href =
resolved?.href ??
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
const sharedProps: React.ComponentProps<typeof Button> = {
...buttonProps,
insights: {
type: 'link_click' as const,
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
},
href,
disabled: href === undefined,
};
const button = (
<Button
{...buttonProps}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
href={href}
disabled={href === undefined}
/>
);
const button =
href &&
context.contentContext &&
isSiteAuthLoginHref(context.contentContext.linker, href) ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
if (inline.data.ref && !resolved) {
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
@@ -50,6 +50,10 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
props,
specUrl: publicURL,
context: context.contentContext,
expandAllResponses:
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
})}
className="openapi-block"
/>
@@ -46,12 +46,13 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
return (
<BaseOpenAPISchemas
schemas={data.schemas}
data={data}
grouped={block.data.grouped}
context={getOpenAPIContext({
props,
specUrl: publicURL,
context: context.contentContext,
expandAllModelSections: data['x-expandAllModelSections'],
})}
className="openapi-block"
/>
@@ -52,6 +52,10 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
props,
specUrl: publicURL,
context: context.contentContext,
expandAllResponses:
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
expandAllModelSections:
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
})}
className="openapi-block"
/>
@@ -2,8 +2,6 @@ import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
@@ -26,16 +24,19 @@ export function getOpenAPIContext(args: {
props: BlockProps<AnyOpenAPIOperationsBlock | OpenAPISchemasBlock | OpenAPIWebhookBlock>;
specUrl: string | null;
context: GitBookAnyContext | undefined;
expandAllResponses?: boolean;
expandAllModelSections?: boolean;
}): OpenAPIContextInput {
const { props, specUrl, context } = args;
const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
const { block } = props;
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
const proxyUrl = context
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
: undefined;
const proxyUrl =
context && props.context.mode !== 'print'
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
: undefined;
return {
specUrl,
@@ -75,12 +76,7 @@ export function getOpenAPIContext(args: {
ancestorBlocks={props.ancestorBlocks}
isEstimatedOffscreen={props.isEstimatedOffscreen}
context={props.context}
style={tcls([
headingProps.deprecated ? 'line-through' : undefined,
headingProps.deprecated || !!headingProps.stability
? '[&>div]:pt-0'
: undefined,
])}
style={headingProps.deprecated ? 'line-through' : undefined}
block={{
object: 'block',
key: `${block.key}-heading`,
@@ -97,7 +93,8 @@ export function getOpenAPIContext(args: {
}}
/>
),
defaultInteractiveOpened: props.context.mode === 'print',
expandAllResponses: expandAllResponses || props.context.mode === 'print',
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
id: block.meta?.id,
blockKey: block.key,
locale,
@@ -4,12 +4,13 @@
/* Layout Components */
.openapi-operation,
.openapi-schemas,
.openapi-webhook {
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
.openapi-webhook,
.openapi-schemas-single {
@apply flex-1 flex flex-col gap-6 mb-14 min-w-0;
}
.openapi-schemas {
@apply flex flex-col mb-14 gap-0 flex-1;
.openapi-schemas:not(.openapi-schemas-single) {
@apply gap-0;
}
.openapi-schemas-title {
@@ -33,6 +34,10 @@
@apply flex flex-row gap-2 mt-[0.75em];
}
.openapi-summary-tags + .heading {
@apply pt-0;
}
.openapi-deprecated,
.openapi-stability {
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
@@ -144,7 +149,7 @@
}
.openapi-column-preview {
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:py-6 sticky max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
}
.openapi-column-preview-body {
@@ -233,7 +238,7 @@
}
.openapi-schema-type {
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem] whitespace-normal;
}
.openapi-schema-type:only-child {
@@ -32,7 +32,7 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
})();
return (
<div className={tcls('mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8', style)}>
<div className={tcls('flex flex-row gap-4 md:gap-8', style)}>
<div className="relative select-none">
<div
className={tcls(
@@ -4,8 +4,7 @@ import { tcls } from '@/lib/tailwind';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import { getColumnWidth } from './ViewGrid';
import styles from './table.module.css';
import { getColumnWidth } from './layout';
import { getColumnVerticalAlignment } from './utils';
export function RecordRow(
@@ -16,9 +15,19 @@ export function RecordRow(
}
) {
const { view, autoSizedColumns, fixedColumns, block, context } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
return (
<div className={styles.row} role="row">
<div
className={tcls(
'group/row flex',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
)}
role="row"
>
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
column,
@@ -26,6 +35,7 @@ export function RecordRow(
autoSizedColumns,
fixedColumns,
});
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
@@ -33,7 +43,13 @@ export function RecordRow(
<div
key={column}
role="cell"
className={tcls(styles.cell)}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle',
isStickyFirstColumnCell
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
: undefined
)}
style={{
width: columnWidth,
minWidth: columnWidth || '100px',
@@ -0,0 +1,179 @@
'use client';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { tcls } from '@/lib/tailwind';
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
interface StickyViewGridProps {
className?: string;
header?: ReactNode;
stickyHeader?: boolean;
tableClassName?: string;
children: ReactNode;
}
export function StickyViewGrid(props: StickyViewGridProps) {
if (props.stickyHeader) {
return <StickyHeaderOverlayScrollGrid {...props} />;
}
return <DefaultHeaderScrollGrid {...props} />;
}
function DefaultHeaderScrollGrid({
className,
header,
tableClassName,
children,
}: StickyViewGridProps) {
const resolvedTableClassName = tableClassName ?? 'w-fit';
return (
<div className={className}>
<div className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle">
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
<div className={tcls('flex', 'flex-col', resolvedTableClassName)}>
{header}
{children}
</div>
</div>
</div>
</div>
);
}
function StickyHeaderOverlayScrollGrid({
className,
header,
tableClassName,
children,
}: StickyViewGridProps) {
const rootRef = useRef<HTMLDivElement>(null);
const stickyHeaderRef = useRef<HTMLDivElement>(null);
const bodyScrollRef = useRef<HTMLDivElement>(null);
const bodyTableRef = useRef<HTMLDivElement>(null);
const onStickyHeaderWheel = useCallback((event: WheelEvent) => {
const bodyScrollElement = bodyScrollRef.current;
if (!bodyScrollElement) {
return;
}
const horizontalDelta = event.deltaX || (event.shiftKey && event.deltaY ? event.deltaY : 0);
if (horizontalDelta === 0) {
return;
}
bodyScrollElement.scrollLeft += horizontalDelta;
event.preventDefault();
}, []);
useEffect(() => {
const stickyHeaderElement = stickyHeaderRef.current;
if (!stickyHeaderElement) {
return;
}
stickyHeaderElement.addEventListener('wheel', onStickyHeaderWheel, { passive: false });
return () => {
stickyHeaderElement.removeEventListener('wheel', onStickyHeaderWheel);
};
}, [onStickyHeaderWheel]);
const syncStickyLayout = useCallback(() => {
const rootElement = rootRef.current;
const bodyScrollElement = bodyScrollRef.current;
const bodyTableElement = bodyTableRef.current;
if (!rootElement || !bodyScrollElement || !bodyTableElement) {
return;
}
rootElement.style.setProperty(
'--table-sticky-scroll-left',
`${-bodyScrollElement.scrollLeft}px`
);
rootElement.style.setProperty(
'--table-sticky-table-width',
`${bodyTableElement.scrollWidth}px`
);
rootElement.dataset.scrollable = `${
bodyScrollElement.scrollWidth > bodyScrollElement.clientWidth + 1
}`;
}, []);
useLayoutEffect(() => {
syncStickyLayout();
}, [syncStickyLayout]);
useScrollListener(syncStickyLayout, bodyScrollRef);
useEffect(() => {
const bodyScrollElement = bodyScrollRef.current;
const bodyTableElement = bodyTableRef.current;
if (!bodyScrollElement && !bodyTableElement) {
return;
}
const resizeObserver = new ResizeObserver(syncStickyLayout);
if (bodyScrollElement) {
resizeObserver.observe(bodyScrollElement);
}
if (bodyTableElement) {
resizeObserver.observe(bodyTableElement);
}
return () => {
resizeObserver.disconnect();
};
}, [syncStickyLayout]);
const resolvedTableClassName = tableClassName ?? 'w-fit';
return (
<div className={className}>
<div
ref={rootRef}
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
data-scrollable="false"
>
{header ? (
<div
ref={stickyHeaderRef}
className={tcls(
'sticky z-20 w-full min-w-0 max-w-full overflow-hidden rounded-t-[inherit]',
'[top:var(--toc-top-offset,var(--outline-top-offset,0px))]'
)}
>
<div
className={tcls(
'flex',
'flex-col',
resolvedTableClassName,
// The sticky header is rendered outside the scroll container, so it
// needs to mirror the body's horizontal scroll position.
'[transform:translateX(var(--table-sticky-scroll-left,0px))]'
)}
style={{ width: 'var(--table-sticky-table-width)' }}
>
{header}
</div>
</div>
) : null}
<div
ref={bodyScrollRef}
className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle group-data-[scrollable=true]/table:mx-px group-data-[scrollable=true]/table:rounded-none group-data-[scrollable=true]/table:border-0"
>
<div
ref={bodyTableRef}
className={tcls('flex', 'flex-col', resolvedTableClassName)}
>
{children}
</div>
</div>
</div>
</div>
);
}
@@ -1,10 +1,14 @@
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { StickyViewGrid } from './StickyViewGrid';
import { ViewCards } from './ViewCards';
import { ViewGrid } from './ViewGrid';
import { ViewGrid, ViewGridHeader } from './ViewGrid';
import { getViewGridLayout, hasVisibleHeader } from './layout';
export type TableRecordKV = [string, DocumentTableRecord];
@@ -15,7 +19,7 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
}
export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document } = props;
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
@@ -32,15 +36,78 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
{...props}
/>
);
case 'grid':
case 'grid': {
const gridProps = {
...props,
view: block.data.view,
isOffscreen,
records,
};
const { tableWidth } = getViewGridLayout({
block,
view: block.data.view,
mode: context.mode,
});
const tableContainerClassName =
tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
const withHeader = hasVisibleHeader(block, block.data.view);
const withStickyHeader =
withHeader && context.mode !== 'print' && block.data.view.stickyHeader === true;
const withStickyFirstColumn =
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
if (withStickyHeader || withStickyFirstColumn) {
return (
<StickyViewGrid
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
stickyHeader={withStickyHeader}
tableClassName={tableContainerClassName}
header={
withHeader ? (
<ViewGridHeader
{...gridProps}
tableClassName={tableContainerClassName}
className={tcls(
withStickyHeader
? [
'mb-0 border-t border-r border-l',
'group-data-[scrollable=false]/table:mb-1',
'group-data-[scrollable=false]/table:rounded-b-lg',
'group-data-[scrollable=true]/table:border-t-0',
'group-data-[scrollable=true]/table:border-x-0',
]
: undefined
)}
/>
) : undefined
}
>
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
</StickyViewGrid>
);
}
return (
<ViewGrid
view={block.data.view}
isOffscreen={isOffscreen}
records={records}
{...props}
/>
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
<div
className={tcls(
'w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle '
)}
>
<div className={tcls('flex', 'flex-col', tableContainerClassName)}>
{withHeader ? (
<ViewGridHeader
{...gridProps}
tableClassName={tableContainerClassName}
className={tableContainerClassName}
/>
) : null}
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
</div>
</div>
</div>
);
}
default:
assertNever(block.data.view);
}

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