Compare commits

..

131 Commits

Author SHA1 Message Date
utkuufuk 39150d5a8f add table row search matching logic 2026-06-16 18:08:16 +03:00
Nolann B. 509389c7e5 Update @gitbook/api to latest version (#4315) 2026-06-16 14:30:10 +02:00
Nolann B. 88b94b5d36 Handle circular oneOf with discriminator and allOf in OpenAPI schemas (#4070) 2026-06-16 13:34:17 +02:00
Greg Bergé 251630c49f Use page actions items list for ordering and default action (#4302)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:19:20 +02:00
Zeno Kapitein acf2719d06 Stabilize AI Assistant visual regression tests (#4305)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:19:56 +02:00
Zeno Kapitein ddba8031ec Reference the current page when opening the Assistant (#4308)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:35:15 +02:00
Zeno Kapitein 72a1596ae8 Fix AI assistant forms overflowing the chat panel (#4309)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:22:57 +02:00
Nolann B. 5b36c59061 Fix PDF export failing on API Reference pages (#4307) 2026-06-12 13:43:38 +00:00
Zeno Kapitein 77c717c251 Group an assistant turn's tool calls into a single expandable (#4303)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:26:27 +02:00
Samy Pessé 60310b4c83 Version Packages (#4278)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-12 14:55:55 +02:00
conico974 6eabaecbad Implement getVariables method for expression evaluation (#4304) 2026-06-12 14:26:17 +02:00
Zeno Kapitein 423bbd06d9 Create Claude launch.json (#4301) 2026-06-12 12:52:24 +02:00
conico974 36f19c8018 Add LLMs directive for documentation links (#4300) 2026-06-12 12:09:25 +02:00
Zeno Kapitein 2ecbdd8f62 add navigateToPage assistant tool (#4299)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:57:55 +02:00
Nolann B. c727cc8b9d Fix inflated visitor counts from unstable anonymous visitorId (#4293) 2026-06-12 00:52:59 +02:00
Zeno Kapitein 2c729d461e Rework Assistant “based on your context” popover wording (#4298) 2026-06-11 22:19:32 +02:00
Tomek d83ea4ce14 chore(a11y): increase width of the collapse/expand arrow in menu items (#4297) 2026-06-11 14:02:38 +00:00
spastorelli bac8608e78 Add support for passing visitor claims to integration webframes (#4291) 2026-06-11 09:52:26 +02:00
Greg Bergé 7a1236a3a5 Fix search results linking to page instead of section anchor (#4295)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:23:59 +00:00
Zeno Kapitein ed80bd9e7a Add recent search (and chat) queries (#4225) 2026-06-10 18:45:07 +02:00
Zeno Kapitein 7036b67307 Fix window.GitBook.registerAssistant is being called twice (#4296) 2026-06-10 15:54:38 +00:00
Zeno Kapitein 6bd522b322 Update site section tabs with better column calc and animations (#4252) 2026-06-10 17:35:38 +02:00
Zeno Kapitein 74fc3f74d7 Fix: Submit feedback confirmation does not auto close (#4240) 2026-06-10 17:35:29 +02:00
conico974 3e384c679e Add suggested follow-up questions to AI answer markdown response (#4284) 2026-06-10 17:29:08 +02:00
Greg Bergé f1fa1061db Upgrade bun.lock (#4294) 2026-06-10 17:09:10 +02:00
Addison 4f754a73a3 Add information about GitBook to page markdown (#4292) 2026-06-09 19:53:07 +02:00
conico974 62d6d28888 Fix context handling in resolveContentRef for page resolution issues (#4290) 2026-06-09 15:10:03 +02:00
conico974 fd73a9912b Fix caching strategy for icon fetching (#4289) 2026-06-08 13:46:32 +02:00
Johan Preynat 595435ac4a Ensure local search matches remote filter when there are no sections (#4287) 2026-06-04 19:14:54 +02:00
Addison aa9d41a1b6 Update footer for llms (#4286) 2026-06-04 16:19:02 +02:00
conico974 30089b404c Adjust caching strategy for icon fetching based on runtime environment (#4283)
Co-authored-by: Steven H <steven@gitbook.io>
2026-06-02 12:00:38 +02:00
Nolann B. 92b68560e1 Add Ask AI in CodeBlock (#4220) 2026-06-02 00:40:54 +02:00
Nolann B. f158064817 Match OpenAPI operation paths regardless of trailing slash (#4282) 2026-05-29 12:16:24 +02:00
Greg Bergé 2885a137f8 Fix heading anchor alignment with heading text and translate its accessibility label (#4281) 2026-05-29 09:38:47 +02:00
spastorelli 0ffb74a6f8 Fix issue where MCP POST requests with expired tokens returns 307 instead of 401 (#4280) 2026-05-28 16:35:12 +02:00
Nolann B. b116a0a2a4 Fix OpenAPI code sample box not scrolling on initial load (#4279) 2026-05-28 12:04:32 +02:00
Greg Bergé b9bb535d52 Full screen view for Mermaid diagrams (#4275)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:17:21 +02:00
Samy Pessé 257ca09a5e Version Packages (#4263)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tomek Gargula <tomasz@gitbook.com>
2026-05-28 07:12:45 +00:00
Tomek f405fffa06 feat: add support for icons in tabs (#4277) 2026-05-27 15:22:25 +00:00
Tomek 16a54b6bb9 Improve accessibility in published docs (#4276) 2026-05-27 12:40:25 +00:00
Greg Bergé 526a928420 Fix annotation hover interaction when card has a target link (#4274) 2026-05-27 12:02:20 +02:00
conico974 c201abab5f Don't display agents instructions for page action (#4272) 2026-05-27 10:41:53 +02:00
Greg Bergé 3697dfefed Add page actions layout visibility (#4270) 2026-05-22 12:37:37 +02:00
spastorelli 287203d197 Show MCP page actions for VA & adaptive sites (#4268) 2026-05-22 08:47:13 +02:00
conico974 0b38f6c93b Update regional cache settings (#4267) 2026-05-20 14:25:59 +02:00
conico974 8ce64f4862 Remove remote from use cache (#4259) 2026-05-20 14:12:51 +02:00
Greg Bergé 127ef54b0f Add updates tag filtering (#4265) 2026-05-20 12:42:51 +02:00
Greg Bergé 67f31aaa7a Always allow LLMs to read the content (#4264) 2026-05-20 09:39:35 +02:00
Greg Bergé 51fef84d39 Fix logo size variations (#4266) 2026-05-19 20:55:45 +02:00
Greg Bergé 0c577a00de Allow CORS for the same root domain (#4262)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:41:07 +02:00
Samy Pessé 8b6dc6dbbd Version Packages (#4255)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-05-19 13:24:41 +02:00
Antoine Coutellier 2e7ba40a25 feat: support integrations inside reusable content blocks (#4250) 2026-05-19 11:29:02 +02:00
Greg Bergé b70b8fd2ca Fix llms.txt not including all content (#4260) 2026-05-18 21:33:07 +02:00
Greg Bergé a9ba996953 Fix left/right aligment of images with caption (#4261) 2026-05-18 21:32:22 +02:00
Brett Jephson 47c8ccba6f Fix: clipping of FA icons (#4257) 2026-05-18 15:09:41 +01:00
conico974 e9842feeef Bump Next.js to version 16.2.6 and fix oom on dev (#4258) 2026-05-18 11:12:47 +02:00
Greg Bergé f4b4fe15c1 Add missing translations (#4254) 2026-05-16 07:16:49 +02:00
Greg Bergé 06b9fd542a Do not add target blank on mailto (#4256) 2026-05-15 20:43:39 +02:00
Taran Vohra 76077f9f6d Fix publish workflow (#4245) 2026-05-15 10:51:43 +02:00
Greg Bergé 7b65f7c0d5 Update iframely embed URL (#4253) 2026-05-14 19:04:34 +02:00
Greg Bergé 37a9453917 Allow to copy in embed (#4251) 2026-05-13 04:49:14 +00:00
Viktor Renkema d994875bea Support code block // [!code -- or ++] notation (#4248) 2026-05-12 15:39:21 +02:00
Claire Chabas a9b5521f3a Fix anchor URL resolution (#4249) 2026-05-11 16:02:20 +02:00
Greg Bergé 91fadb08ed Fix turbo burning Mac CPU (#4247) 2026-05-11 11:36:23 +00:00
Samy Pessé b55605f25e Version Packages (#4199)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-05-08 13:00:03 +00:00
Addison 6ba5e54d19 add isComposing to search (#4244) 2026-05-08 18:47:20 +09:00
Samy Pessé 56aeb19e49 Fix events tracked from the middleware missing some properties (#4242) 2026-05-07 23:15:34 +02:00
conico974 ebab8188f6 Bump @opennextjs/aws and @opennextjs/cloudflare versions (#4243) 2026-05-07 17:01:05 +02:00
Zeno Kapitein 8064c95267 Fix publish workflow npm upgrade in Bun repo (#4239) 2026-05-07 09:36:14 +02:00
conico974 2cef2e02c8 Bump Next.js version to 16.2.5 (#4241) 2026-05-06 22:02:10 +02:00
conico974 8bfced2e0d Add negotiator for markdown acceptance in middleware (#4238)
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 14:59:38 +02:00
conico974 61ee4e3637 Add tests for markdown serving based on user agents (#4227) 2026-05-06 13:52:09 +02:00
Greg Bergé 927aa89213 Add "Go to home" button on 404 page (#4236) 2026-05-05 13:12:12 +00:00
Greg Bergé f29d1e2f16 Round images when site customization border style is rounded (#4237) 2026-05-05 14:50:24 +02:00
conico974 3a9efb8aed bust revision page markdown (#4235) 2026-05-05 14:39:34 +02:00
conico974 f71ab8426c Bust page document data cache (#4234) 2026-05-04 16:15:27 +02:00
conico974 a3d1558f99 Disable regional cache for debugging purposes (#4233) 2026-05-04 14:03:29 +02:00
conico974 ca53869e2d Reduce cache TTL for regional cache and log cache debug (#4232) 2026-05-04 13:32:36 +02:00
conico974 33598e665b Add logging for cache retrieval in GitbookIncrementalCache (#4231) 2026-05-04 11:47:47 +02:00
Samy Pessé 9045ba5248 Remove progressive rollout for markdown stable links (#4230) 2026-05-01 21:56:28 +02:00
Rodrigo Castro 7061fc2223 Fix hidden pages in sitemap exports (#4216) 2026-04-30 14:04:37 +01:00
Samy Pessé 0c9e3952b7 Increase rollout of markdown stable links to 50% (#4228) 2026-04-30 11:37:37 +02:00
Samy Pessé 4b78672135 Fix links to other site-spaces / sections in llms.txt (#4222) 2026-04-30 08:43:32 +02:00
conico974 10289e4881 Revert search worker changes and add error handling (#4226)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:59:05 +02:00
conico974 8b6a6df1d6 Move search into a web worker (#4223)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 18:42:44 +02:00
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
335 changed files with 18048 additions and 5260 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve Assistant form controls
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Improve AI agent detection and readability by outputting 200 for page not found.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "🚀 Dev server",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "bun install && bun dev"],
"port": 3000,
"autoPort": false
}
]
}
@@ -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
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
+7 -3
View File
@@ -15,6 +15,10 @@ jobs:
id-token: write
pull-requests: write
contents: write
env:
# The repo is managed by Bun, but publishing uses npm for provenance.
# Keep Corepack from rejecting packageManager: bun@... when npm runs.
COREPACK_ENABLE_PROJECT_SPEC: 0
steps:
- name: Checkout Repo
uses: actions/checkout@v3
@@ -26,9 +30,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 +50,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 }}
+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"
]
},
+324 -1533
View File
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -6,8 +6,8 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.2",
"@changesets/cli": "^2.31.0",
"turbo": "^2.9.18",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
@@ -17,8 +17,9 @@
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2",
"axios": "1.8.4"
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
},
"private": true,
"scripts": {
@@ -42,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.175.0",
"@gitbook/api": "0.184.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -54,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.6": "patches/next@16.2.6.patch"
}
}
+18
View File
@@ -1,5 +1,23 @@
# @gitbook/embed
## 0.5.1
### Patch Changes
- 37a9453: Allow copying in embed
- Updated dependencies [47c8ccb]
- @gitbook/icons@0.5.0
## 0.5.0
### Minor Changes
- 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 -47
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`
@@ -285,44 +288,6 @@ Display GitBook branding in the embed. Defaults to true.
trademark: true
```
### Theming and `color-scheme` (CSS-first)
The embed supports both site-controlled theming and CSS-driven theming.
Precedence (highest → lowest):
- **Site mode / forced theme**: if the GitBook site does not support multiple themes, the embed is forced to the site’s default theme.
- **Visitor preference**: if the site supports multiple themes and the visitor has previously selected a theme on the site, that preference is remembered.
- **Browser/OS default**: otherwise the embed follows the browser/OS preference (`prefers-color-scheme`).
You can also drive the embed theme from CSS by setting `color-scheme` on the iframe element (or a parent it inherits from). When the iframe resolves to an explicit `color-scheme: light` or `color-scheme: dark`, that value is propagated into the embedded content so it renders consistently.
Standalone widget example:
```css
/* Force the GitBook widget iframe to render in dark mode */
#gitbook-widget-iframe {
color-scheme: dark;
}
```
### `colorScheme`
Available in: Standalone script, NPM package, React components
Force the embed to render in a specific color scheme.
- **Type**: `'light' | 'dark'`
- **Default**: `undefined` (follow site/visitor preference/system)
```javascript
GitBook('configure', {
colorScheme: 'dark'
});
```
To clear an override, omit `colorScheme` (or set it to `undefined` in JS) in a subsequent `configure` call.
### `actions`
Available in: Standalone script, NPM package, React components
@@ -495,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
+2 -2
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.4.0",
"version": "0.5.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -32,7 +32,7 @@
"scripts": {
"build": "bun run build-lib && bun run build-standalone",
"build-lib": "tsdown",
"build-standalone": "bun run ./scripts/build-standalone.ts",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
@@ -1,36 +0,0 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { Features, transform } from 'lightningcss';
/**
* Build the standalone embed script.
* Bun's default CSS transpiler (which is a port of LightningCSS) strips out the native light-dark() function in favour of a polyfill.
* Light-dark() is widely supported now, and the polyfill requires you to set a data attribute on the element instead of relying on plain CSS.
* This script's purpose is to pass a feature flag to the CSS transpiler to keep the native light-dark() behavior.
*/
await new Promise<void>((resolve, reject) => {
const child = spawn(
'bun',
['build', 'src/standalone/index.ts', '--bundle', '--minify', '--outdir=standalone'],
{ stdio: 'inherit' }
);
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`bun build failed with exit code ${code ?? 'unknown'}`));
});
});
const sourceCSS = await readFile('src/standalone/style.css');
const transformedCSS = transform({
filename: 'src/standalone/style.css',
code: sourceCSS,
minify: true,
// Keep native light-dark() behavior scoped to element color-scheme.
exclude: Features.LightDark,
});
await writeFile('standalone/index.css', transformedCSS.code);
@@ -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);
}
@@ -44,6 +44,16 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
if (!iframe.contentWindow) {
throw new Error('Iframe must have a content window');
}
const allowTokens = iframe.allow
.split(';')
.map((token) => token.trim())
.filter(Boolean);
if (!allowTokens.includes('clipboard-write')) {
iframe.allow = [...allowTokens, 'clipboard-write'].join('; ');
}
const channel = createChannel(iframe.contentWindow);
channel.receive((message: FrameToParentMessage) => {
@@ -64,7 +74,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 -6
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[];
@@ -83,11 +83,6 @@ export type GitBookEmbeddableConfiguration = {
* Display a close button inside the assistant.
*/
closeButton?: boolean;
/**
* Force the embed to render in a specific color-scheme.
*/
colorScheme?: 'light' | 'dark';
};
/**
+8 -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) {
@@ -72,7 +76,9 @@ export function GitBookFrame(props: GitBookFrameProps) {
src={frameURL}
width="100%"
height="100%"
allow="clipboard-write"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+6 -46
View File
@@ -55,7 +55,6 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let lastPushedColorScheme: 'light' | 'dark' | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
@@ -65,7 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
trademark: true,
};
@@ -86,43 +85,6 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
/** Resolved `color-scheme` from the iframe element (incl. inheritance from `#gitbook-widget-window`). */
function colorSchemeFromIframe(): 'light' | 'dark' | undefined {
if (!widgetIframe) return undefined;
const v = getComputedStyle(widgetIframe).colorScheme.trim().toLowerCase();
return v === 'dark' || v === 'light' ? v : undefined;
}
function pushColorSchemeToFrame() {
if (!_frame) return;
const desired = frameConfiguration.colorScheme ?? colorSchemeFromIframe();
if (desired === lastPushedColorScheme) return;
lastPushedColorScheme = desired;
_frame.configure({
...frameConfiguration,
colorScheme: desired,
});
}
/** Re-push when the host page or widget chrome changes theme (class/style) or OS preference changes. */
function installHostThemeBridge() {
const onChange = () => pushColorSchemeToFrame();
window.matchMedia?.('(prefers-color-scheme: dark)')?.addEventListener?.('change', onChange);
if (typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(onChange);
const opts: MutationObserverInit = {
attributes: true,
attributeFilter: ['class', 'style'],
};
observer.observe(document.documentElement, opts);
// Safari: `color-scheme` on `#gitbook-widget-window` does not always surface on `<html>`.
observer.observe(widgetWindow, opts);
}
installHostThemeBridge();
function getClient() {
if (!_client) {
throw new Error(
@@ -137,9 +99,12 @@ function getIframe() {
const client = getClient();
widgetIframe?.remove();
lastPushedColorScheme = undefined;
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.allow = 'clipboard-write';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
@@ -150,8 +115,6 @@ function getIframe() {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
pushColorSchemeToFrame();
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -229,12 +192,9 @@ const GitBook = (...args: StandaloneCalls) => {
}
}
const { frame } = getIframe();
// Always propagate configuration updates, even when color-scheme doesn't change.
frame.configure({
getIframe().frame.configure({
...frameConfiguration,
});
pushColorSchemeToFrame();
break;
}
case 'clearChat':
+15 -14
View File
@@ -8,6 +8,14 @@
--gitbook-widget-radius: .5rem;
--gitbook-widget-text-size: 1rem;
--gitbook-widget-text-color: #656973;
--gitbook-widget-border-color: #e5e5e5;
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
--gitbook-widget-background-solid: #FFFFFF;
--gitbook-widget-background-solid-hover: #FBFBFB;
--gitbook-widget-icon-size: 1.25rem;
--gitbook-widget-window-width: 28rem; /* 448px */
@@ -21,20 +29,13 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
#gitbook-widget-button,
#gitbook-widget-window {
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
--gitbook-widget-background-solid: light-dark(#FFFFFF, #f0f0f0);
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #f0f0f0);
&[data-color-scheme=light] {
color-scheme: light;
}
&[data-color-scheme=dark] {
color-scheme: dark;
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/expr
## 1.3.0
### Minor Changes
- 6eabaec: Implement a getVariables function for ExpressionRuntime
## 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.3.0",
"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 }) => {
@@ -153,6 +195,49 @@ describe('ExpressionRuntime', () => {
);
});
describe('getVariables', () => {
it.each([
{
scenario: 'single variable',
condition: 'isBetaUser === true',
expectedVariables: ['isBetaUser'],
},
{
scenario: 'multiple variables',
condition: 'useProductA && !isBetaUser',
expectedVariables: ['useProductA', 'isBetaUser'],
},
{
scenario: 'member expression',
condition: 'user.role === "admin"',
expectedVariables: ['user.role'],
},
{
scenario: 'nested member expression with method call',
condition: 'products.includes("productA") && userSegments.alpha',
expectedVariables: ['products.includes', 'userSegments.alpha'],
},
])(
'should return variables used in expression: $scenario',
({ condition, expectedVariables }) => {
expect(runtime.getVariables(condition)).toEqual(expectedVariables);
}
);
it.each([
{
scenario: 'invalid syntax',
condition: 't}=d',
},
{
scenario: 'non conditional expression',
condition: 'const a = 1;',
},
])('should return an empty array for invalid expressions: $scenario', ({ condition }) => {
expect(runtime.getVariables(condition)).toEqual([]);
});
});
describe.skip('generate', () => {
it.each([
{
+23 -1
View File
@@ -11,7 +11,7 @@ import {
import { parse as parseLoose } from 'acorn-loose';
import escodegen from 'escodegen';
import evalESTreeExpr from 'eval-estree-expression';
const { evaluate } = evalESTreeExpr;
const { evaluate, variables } = evalESTreeExpr;
import { AutoComplete } from './autocomplete';
import { ExpressionError } from './errors';
@@ -162,6 +162,28 @@ export class ExpressionRuntime {
.join('');
}
/**
* Given an expression, returns a list of variables used in the expression.
*/
public getVariables(expr: string): string[] {
try {
const parsed = this.parse(expr);
if (parsed.invalidNodes.length > 0) {
throw new ExpressionError('Invalid nodes found when parsing');
}
return variables(parsed.result, {
functions: true,
withMembers: true,
generate: escodegen.generate,
});
} catch (error) {
this.#logger.error(`Error while parsing expression ${expr} to get variables`, error);
return [];
}
}
/**
* Parses a binary expression and returns an @ExpressionParserResult.
*/
@@ -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) => ({
+10
View File
@@ -41,6 +41,16 @@ declare module 'eval-estree-expression' {
options?: EvalESTreeExpressionOptions
): Promise<any>;
/**
* Given an ESTree-compliant AST node, returns a list of variables used in the expression.
* @param ast An object representing an ESTree-compliant AST node.
* @param options Options for evaluation and compilation.
*/
export function variables<ASTNode>(
ast: ASTNode,
options?: EvalESTreeExpressionOptions
): string[];
/**
* Evaluates an ESTree expression synchronously against a given context.
* @param expression - An object representing an ESTree-compliant AST node.
+102
View File
@@ -1,5 +1,107 @@
# gitbook
## 0.27.0
### Minor Changes
- aa9d41a: Update llms.txt footer for GitBook sites
- 4f754a7: Add GitBook information to footer of markdown pages
- 2ecbdd8: Add a `navigateToPage` assistant tool that lets the AI open a documentation page on the reader's behalf. The page opens instantly (no confirmation) and is pushed to the browser history so the reader can navigate back.
### Patch Changes
- 7036b67: Fix custom assistants (e.g. Kapa, Inkeep) being opened twice when clicking "Ask with …" in the search bar.
- 2885a13: Fix heading anchor alignment with heading text and translate its accessibility label.
- b9bb535: Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view.
- b116a0a: Fix OpenAPI CodeBlock scroll
- d83ea4c: Improve a11y of list item collapse/extend toggle
- 92b6856: Add Ask AI in CodeBlock
- 7a1236a: Fix search results linking to the page instead of the section anchor when a section snippet is displayed.
- b9bb535: Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger.
- Updated dependencies [6eabaec]
- Updated dependencies [f158064]
- @gitbook/expr@1.3.0
- @gitbook/react-openapi@1.5.13
## 0.26.2
### Patch Changes
- 67f31aa: Allow user-triggered AI assistants (ChatGPT-User, Claude-User, Perplexity-User) to read pages on non-indexable sites, so end-users can pull content into an LLM without an MCP connection. Search engines and training crawlers remain blocked.
- 0c577a0: Allow CORS for the same root domain
- 51fef84: Remove the date-gated header logo height logic and always apply the compact logo size to prevent size shifts between sections and translations.
- f405fff: Add icon support in tab items
- 3697dfe: Add support for hiding page actions from page layout options.
- 127ef54: Add tag filtering controls for Updates blocks.
## 0.26.1
### Patch Changes
- b70b8fd: Fix llms-full.txt pagination to include pages from all site sections.
- 6ba5e54: Support isComposing for search
- 47c8ccb: Render icons as svg markup
- a9ba996: Fix left/right aligment of images with caption
- 56aeb19: Fix events tracked from the middleware missing some properties.
- Updated dependencies [47c8ccb]
- Updated dependencies [37a9453]
- @gitbook/icons@0.5.0
- @gitbook/embed@0.5.1
- @gitbook/react-contentkit@0.7.16
## 0.26.0
### Minor Changes
- d3d5150: Add "Content-Signal" to "robots.txt"
- 7595706: Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
- a17ab65: Support variant selector in hidden section
### Patch Changes
- d1a2958: Skip AI questions when it matches the example provided to agents / crawlers.
- e3aca9e: Fix embeddable search links
- 4b78672: Fix links to other spaces/sections in the llms.txt.
- d1a2958: Pass visitor session to Ask AI.
## 0.25.1
### Patch Changes
- 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
+12 -10
View File
@@ -110,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',
@@ -222,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',
+110 -39
View File
@@ -31,12 +31,24 @@ import {
headerLinks,
runTestCases,
setTimeToMorning,
waitForAIChatResponse,
waitForCookiesDialog,
waitForCoverImages,
waitForNotFound,
} from './util';
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
// single fixed search, a concise answer, and a fixed number of follow-ups. The
// model is never perfectly deterministic, so `overrideAIResponse` still
// normalizes the rendered content below — this prompt just narrows the variance.
const AI_PROMPT = [
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
'Follow these instructions exactly and do not deviate:',
'1. Do not write any preamble, commentary, or reasoning before acting.',
'2. Perform a single search for exactly "Lorem ipsum".',
'3. Reply with only the first sentence of the first page you find, and nothing else.',
'4. Always end by proposing exactly 3 follow-up suggestions.',
].join('\n');
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
@@ -44,21 +56,45 @@ const overrideAIInitialState = () => {
greeting.textContent = 'Good morning';
}
};
/**
* Normalize the non-deterministic content of an AI response before screenshotting,
* while preserving the surrounding structure (message bubbles, tool/activity
* summary, response container, suggestion buttons) so visual regressions in the
* chat chrome are still caught. The actual answer formatting is covered separately
* by the deterministic page tests, since the AI response renders through the same
* `DocumentView`.
*
* Must run only once the chat is no longer `aria-busy` (the response has fully
* settled), otherwise React re-renders from late stream events will clobber these
* mutations. See `waitForAIChatResponse`.
*/
const overrideAIResponse = () => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (userMessage) {
// The user's prompt varies in length; pin it to a fixed string.
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
userMessage.textContent = '[Replaced message] Chat message sent by the user';
}
const assistantMessage = document.querySelectorAll(
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
);
assistantMessage.forEach((message) => {
message.innerHTML = '[Replaced message] AI chat response';
});
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
suggestions.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
// The assistant's answer text is non-deterministic; replace the rendered
// document body while keeping the `.ai-response-document` container.
document
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
.forEach((message) => {
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
});
// The "Explored with N tools" activity label varies with the number of tool
// calls; pin it (the chevron sibling is left intact).
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
summary.textContent = 'Explored';
});
// Follow-up suggestion text varies; pin each label.
document
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
});
};
const searchTestCases: Test[] = [
@@ -82,8 +118,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.
},
},
@@ -169,12 +203,13 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
// Wait for the full response (incl. follow-up suggestions) to settle before
// asserting/screenshotting, rather than racing a fixed suggestion count.
await waitForAIChatResponse(page);
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
{
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
@@ -188,9 +223,9 @@ const searchTestCases: Test[] = [
await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - Button',
@@ -204,9 +239,9 @@ const searchTestCases: Test[] = [
await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
@@ -221,9 +256,9 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-input')).toBeEmpty();
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
},
{
name: 'Ask - AI Mode: Assistant - URL query (Results)',
@@ -239,12 +274,13 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
// Wait for the full response (incl. follow-up suggestions) to settle before
// asserting/screenshotting, rather than racing a fixed suggestion count.
await waitForAIChatResponse(page);
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
},
// Re-applied per viewport so the replacement survives resize-driven re-renders.
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
},
];
@@ -781,7 +817,9 @@ const testCases: TestsCase[] = [
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
// Trademark exists by default
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
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());
@@ -874,6 +912,7 @@ const testCases: TestsCase[] = [
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
fullPage: true,
tests: [
{
name: 'Text',
@@ -1055,8 +1094,8 @@ const testCases: TestsCase[] = [
url: 'blocks/annotations',
run: async (page) => {
await waitForCookiesDialog(page);
await page.waitForSelector('[data-testid="annotation-button"]');
await page.click('[data-testid="annotation-button"]');
await page.waitForSelector('[data-annotation]');
await page.click('[data-annotation]');
},
},
{
@@ -1322,6 +1361,7 @@ const testCases: TestsCase[] = [
pageActions: {
markdown: false,
externalAI: false,
items: [],
},
}),
run: waitForCookiesDialog,
@@ -2220,9 +2260,13 @@ const testCases: TestsCase[] = [
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
await iframe.owner().evaluate(overrideAIInitialState);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIInitialState),
},
{
name: 'API - navigateToPage',
@@ -2256,8 +2300,15 @@ const testCases: TestsCase[] = [
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
AI_PROMPT
);
await iframe.owner().evaluate(overrideAIResponse);
// Wait for the full response to settle before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
{
name: 'Configuration - Suggested questions',
@@ -2283,8 +2334,13 @@ const testCases: TestsCase[] = [
await expect(
iframe.getByTestId('ai-chat-suggested-question').nth(2)
).toHaveText('What can you do?');
await iframe.owner().evaluate(overrideAIInitialState);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIInitialState),
},
{
name: 'Configuration - Custom action buttons',
@@ -2366,8 +2422,15 @@ const testCases: TestsCase[] = [
await actions.nth(3).click();
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
await page.locator('#gitbook-widget-button').click();
await iframe.owner().evaluate(overrideAIResponse);
// Wait for the response posted above to settle before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
{
name: 'Configuration - Custom tools',
@@ -2411,8 +2474,16 @@ const testCases: TestsCase[] = [
await expect(toolConfirmation).toBeVisible({
timeout: 30000,
});
await iframe.owner().evaluate(overrideAIResponse);
// The turn settles (aria-busy clears) once the stream pauses on the
// confirmation control; wait for that before normalizing.
await waitForAIChatResponse(iframe);
},
// Runs inside the iframe (not the parent doc) and per viewport.
normalizeBeforeScreenshot: (page) =>
page
.frameLocator('#gitbook-widget-iframe')
.locator('body')
.evaluate(overrideAIResponse),
},
],
},
+69 -7
View File
@@ -13,6 +13,7 @@ import {
CustomizationIconsStyle,
CustomizationLinksStyle,
CustomizationLocale,
CustomizationPageActionType,
CustomizationSearchStyle,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
@@ -21,7 +22,14 @@ import {
type SiteCustomizationSettings,
SiteExternalLinksTarget,
} from '@gitbook/api';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
import {
type BrowserContext,
type FrameLocator,
type Page,
type Response,
expect,
test,
} from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import type { DeepPartial } from 'ts-essentials';
@@ -39,6 +47,16 @@ export interface Test {
* Test to run
*/
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Re-applied right before every viewport screenshot (after Argos
* stabilization), so it survives re-renders triggered by viewport resizing.
*
* Use this — rather than mutating the DOM once in `run` — to normalize
* non-deterministic content (e.g. AI responses). A one-time mutation in `run`
* is clobbered when React re-renders on resize (e.g. crossing the mobile
* breakpoint), so only the first viewport ends up normalized.
*/
normalizeBeforeScreenshot?: (page: Page) => Promise<void> | void;
/**
* Mode for the test.
*/
@@ -75,6 +93,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[] = [
@@ -155,6 +177,28 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
/**
* Wait for an AI chat response to be fully settled before asserting or
* screenshotting it.
*
* The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`),
* which stays true from the moment a message is sent until the stream — including
* the follow-up suggestion phase — completes. Gating on it avoids the two main
* sources of flakiness: capturing a "thinking" placeholder or a half-streamed
* answer, and running the content normalization while React is still re-rendering
* (which would clobber the replacements).
*
* Argos also waits for `aria-busy` to clear during its own stabilization
* (`waitForAriaBusy`), so this is both an explicit gate and a backstop.
*
* Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe).
*/
export async function waitForAIChatResponse(scope: Page | FrameLocator) {
await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', {
timeout: 60_000,
});
}
export async function setTimeToMorning(page: Page) {
const now = new Date();
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
@@ -240,15 +284,18 @@ 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) {
await waitForTOCScrolling(page);
}
await waitForIcons(page);
// Re-apply per viewport, last — after any resize-driven
// re-render — so normalized content survives to capture.
await testEntry.normalizeBeforeScreenshot?.(page);
},
});
}
@@ -368,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
externalAI: true,
markdown: true,
mcp: true,
items: [
CustomizationPageActionType.Markdown,
CustomizationPageActionType.ExternalAi,
CustomizationPageActionType.Mcp,
],
},
trademark: {
enabled: true,
@@ -392,11 +444,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
export async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urlStates: Record<
type IconURLStates = Record<
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
>;
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
iconStatesWindow.__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
@@ -441,7 +495,15 @@ export async function waitForIcons(page: Page) {
const maskImage = icon.querySelector('[data-testid="mask-image"]');
if (!maskImage) {
throw new Error('No mask-image element');
const inlineContent = icon.querySelector(
'path, circle, ellipse, line, polygon, polyline, rect, g, use'
);
if (inlineContent) {
icon.setAttribute('data-argos-state', 'loaded');
return true;
}
throw new Error('Icon has no inline SVG content or mask-image element');
}
const url = maskImage.getAttribute('href');
+22 -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,10 +35,12 @@ 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,
@@ -40,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",
@@ -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",
@@ -5,7 +5,8 @@ export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
//TODO: bump it again once I figured out the race condition
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,12 +1,15 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
// and cause race conditions. This will be fixed in a future release of OpenNext
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
//TODO: remove, reducing cache ttl of regional cache to help debugging
defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
+26 -16
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.23.2",
"version": "0.27.0",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -19,10 +19,11 @@
"@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": "4.0.1",
"@opennextjs/cloudflare": "1.19.8",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.3",
@@ -31,7 +32,7 @@
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
"@vercel/agent-readability": "^0.2.1",
"@vercel/agent-readability": "^0.4.0",
"ai": "^4.2.2",
"assert-never": "catalog:",
"bidc": "catalog:",
@@ -39,31 +40,35 @@
"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",
"leven": "^4.1.0",
"next": "15.4.11",
"negotiator": "^1.0.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"object-identity": "^0.1.2",
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"p-retry": "^8.0.0",
"quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react": "19.2.4",
"react-aria": "^3.44.0",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -76,6 +81,7 @@
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"tldts": "^7.0.30",
"unified": "^11.0.5",
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
@@ -98,8 +104,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:",
@@ -112,17 +118,17 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.43.0",
"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",
@@ -138,5 +144,9 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
@@ -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} />;
}
@@ -20,6 +20,10 @@ export async function handleMcpRequest(
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;
@@ -4,8 +4,8 @@ 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';
@@ -16,54 +16,41 @@ import type {
SiteSection,
SiteSectionGroup,
SiteSpace,
Space,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { type NextRequest, NextResponse } from 'next/server';
type SearchResultGroup = {
score: number;
items: OrderedComputedResult[];
};
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
.flatMap((resultItem): SearchResultGroup[] => {
.flatMap((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
@@ -73,31 +60,25 @@ export async function POST(request: NextRequest) {
href: resultItem.url,
score: resultItem.score,
};
return [
{
score: resultItem.score,
items: [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) => ({
score: pageItem.score,
items: transformSitePageResult(context, {
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,
}),
}));
})
@@ -107,72 +88,117 @@ export async function POST(request: NextRequest) {
return NextResponse.json(results);
}
function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
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),
});
}
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,
score: pageItem.score,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
},
siteSection && {
icon: siteSection?.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
},
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection?.siteSpaces?.filter(
(s) => s.space.language === space.space.language
).length > 1
).length ?? 0) > 1 && siteSpace
? {
label: getLocalizedTitle(siteSpace, currentLanguage),
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
].filter((item) => item !== undefined),
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,
score: section.score,
})) ?? [];
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(section.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];
}
@@ -11,5 +11,5 @@ export async function GET(
) {
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);
}
@@ -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);
@@ -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({
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'bun:test';
import { normalizePathname, resolveNavigationTarget, toInSiteHref } from './navigation';
const location = {
href: 'https://docs.example.com/guides/intro',
origin: 'https://docs.example.com',
};
describe('resolveNavigationTarget', () => {
it('resolves an absolute same-origin URL to a relative href and pathname', () => {
expect(
resolveNavigationTarget('https://docs.example.com/reference/models', location)
).toEqual({ href: '/reference/models', pathname: '/reference/models' });
});
it('keeps the query string and section anchor in href but not in pathname', () => {
expect(
resolveNavigationTarget(
'https://docs.example.com/reference/models?tab=api#usage',
location
)
).toEqual({ href: '/reference/models?tab=api#usage', pathname: '/reference/models' });
});
it('resolves a relative path against the current location', () => {
expect(resolveNavigationTarget('/reference/models', location)).toEqual({
href: '/reference/models',
pathname: '/reference/models',
});
});
it('rejects a URL pointing to an external site', () => {
const result = resolveNavigationTarget('https://evil.example.org/phishing', location);
expect('error' in result).toBe(true);
});
});
describe('toInSiteHref', () => {
// A docs site served under `/docs/` on a host shared with other content/sites.
const linker = {
siteBasePath: '/docs/',
toLinkForContent: (url: string) => {
const parsed = new URL(url);
// Mirrors the real linker: in-site iff same host AND under the site base path.
if (parsed.hostname === 'gitbook.com' && parsed.pathname.startsWith('/docs/')) {
return parsed.pathname + parsed.search + parsed.hash;
}
return url;
},
};
it('accepts an in-site absolute URL and returns a relative path', () => {
expect(toInSiteHref('https://gitbook.com/docs/guides/intro', linker)).toBe(
'/docs/guides/intro'
);
});
it('accepts an in-site relative path under the site base path', () => {
expect(toInSiteHref('/docs/guides/intro?x=1#y', linker)).toBe('/docs/guides/intro?x=1#y');
});
it('rejects another page on the same host but outside the site base path', () => {
// The reviewer's case: same host, different site/section.
expect(toInSiteHref('https://gitbook.com/pricing', linker)).toBeNull();
expect(toInSiteHref('/pricing', linker)).toBeNull();
});
it('rejects an external host', () => {
expect(toInSiteHref('https://evil.example.org/docs/guides', linker)).toBeNull();
});
});
describe('normalizePathname', () => {
it('strips a trailing slash', () => {
expect(normalizePathname('/guides/intro/')).toBe('/guides/intro');
});
it('keeps the root slash', () => {
expect(normalizePathname('/')).toBe('/');
});
it('decodes percent-encoding so encoded and decoded paths compare equal', () => {
expect(normalizePathname('/h%C3%A9llo')).toBe(normalizePathname('/héllo'));
});
it('treats encoded and decoded paths with a trailing slash as equal', () => {
expect(normalizePathname('/caf%C3%A9/')).toBe(normalizePathname('/café'));
});
});
@@ -0,0 +1,76 @@
import { withTrailingSlash } from '@/lib/paths';
import { isExternalLink } from '../utils/link';
/**
* Resolve a link into a same-site target to navigate to.
*
* Returns an `error` when the URL is malformed or points outside of the documentation site,
* so the assistant can be told it could not navigate.
*/
export function resolveNavigationTarget(
url: string,
location: { href: string; origin: string }
): { href: string; pathname: string } | { error: string } {
let target: URL;
try {
target = new URL(url, location.href);
} catch {
return { error: `Invalid URL: ${url}` };
}
// Only allow navigating within the current documentation site to avoid sending the user to
// an external website without their consent.
if (isExternalLink(target.href, location.origin)) {
return { error: 'Cannot navigate to a page outside of this documentation site.' };
}
return { href: `${target.pathname}${target.search}${target.hash}`, pathname: target.pathname };
}
/**
* Return a site-relative href if `url` points within the current site, otherwise `null`.
*
* Unlike a bare same-origin check, this enforces the site base path, so an assistant cannot
* navigate the reader to another page on the same host (e.g. a marketing page, or a different
* docs site sharing the host such as `gitbook.com/other` or another `/url/...` proxied site).
*/
export function toInSiteHref(
url: string,
linker: { toLinkForContent: (url: string) => string; siteBasePath: string }
): string | null {
if (URL.canParse(url)) {
// toLinkForContent returns a site-relative path for in-site URLs (matching host AND site
// base path), or the raw absolute URL otherwise.
const link = linker.toLinkForContent(url);
return URL.canParse(link) ? null : link;
}
// Relative path: it must live under the site base path.
let pathname: string;
let rest = '';
try {
const parsed = new URL(url, 'https://navigation.invalid');
pathname = parsed.pathname;
rest = `${parsed.search}${parsed.hash}`;
} catch {
return null;
}
return withTrailingSlash(pathname).startsWith(linker.siteBasePath)
? `${pathname}${rest}`
: null;
}
/**
* Normalize a pathname so two equivalent paths compare equal regardless of percent-encoding or a
* trailing slash (e.g. `/h%C3%A9llo/` and `/héllo`). Used to detect when an SPA navigation has
* committed by comparing against `window.location.pathname`.
*/
export function normalizePathname(pathname: string): string {
let decoded = pathname;
try {
decoded = decodeURIComponent(pathname);
} catch {
// Keep the raw value if it isn't valid percent-encoding.
}
return decoded.length > 1 && decoded.endsWith('/') ? decoded.slice(0, -1) : decoded;
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test';
import { type AIChatReference, serializeReferences } from './references';
describe('serializeReferences', () => {
it('returns an empty string when there are no references', () => {
expect(serializeReferences([])).toBe('');
});
it('serializes a single page reference as a markdown link', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' },
];
const result = serializeReferences(refs);
expect(result).toContain('The user is referring to the following page they are reading');
expect(result).toContain('- [Getting started](/getting-started)');
expect(result.endsWith('\n\n---\n\n')).toBe(true);
});
it('falls back to the path when no href is provided', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' },
];
const result = serializeReferences(refs);
expect(result).toContain('- [Overview](getting-started)');
});
it('renders a quoted label when neither href nor path is provided', () => {
const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }];
const result = serializeReferences(refs);
expect(result).toContain('- "Overview"');
expect(result).not.toContain('](');
});
it('uses the plural form for multiple pages', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'One' },
{ type: 'page', id: 'page-2', label: 'Two' },
];
const result = serializeReferences(refs);
expect(result).toContain('the following pages they are reading');
expect(result).toContain('in the context of them');
});
it('serializes a single code block reference', () => {
const refs: AIChatReference[] = [
{
type: 'code-block',
id: 'code-1',
label: 'main.ts',
content: 'const a = 1;',
syntax: 'ts',
},
];
const result = serializeReferences(refs);
expect(result).toContain('The user is referring to the following code block');
expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```');
});
it('escapes backtick fences inside the code content', () => {
const refs: AIChatReference[] = [
{ type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' },
];
const result = serializeReferences(refs);
expect(result).toContain('````md\na ``` b\n````');
});
it('groups page and code references into distinct sections', () => {
const refs: AIChatReference[] = [
{ type: 'page', id: 'page-1', label: 'Quickstart' },
{ type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' },
];
const result = serializeReferences(refs);
const pageIndex = result.indexOf('referring to the following page');
const codeIndex = result.indexOf('referring to the following code block');
expect(pageIndex).toBeGreaterThanOrEqual(0);
expect(codeIndex).toBeGreaterThan(pageIndex);
});
});
@@ -0,0 +1,78 @@
type BaseAIChatReference = {
id: string;
label?: string;
};
export type CodeBlockReference = BaseAIChatReference & {
type: 'code-block';
content: string;
syntax?: string;
};
export type PageReference = BaseAIChatReference & {
type: 'page';
/** Title of the page being referenced. */
label: string;
/** Path of the page, used to help the assistant identify it. */
path?: string;
/** Site-relative href of the page, used to navigate back to it from the chip. */
href?: string;
};
export type AIChatReference = CodeBlockReference | PageReference;
/**
* Serialize the staged references into a preamble prepended to the user's message,
* so the assistant is informed about the context the user is referring to.
*/
export function serializeReferences(refs: AIChatReference[]): string {
if (refs.length === 0) {
return '';
}
const sections: string[] = [];
const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page');
if (pageRefs.length > 0) {
sections.push(serializePageReferences(pageRefs));
}
const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block');
if (codeRefs.length > 0) {
sections.push(serializeCodeBlockReferences(codeRefs));
}
if (sections.length === 0) {
return '';
}
return `${sections.join('\n\n')}\n\n---\n\n`;
}
function serializePageReferences(refs: PageReference[]): string {
const plural = refs.length > 1;
const list = refs
.map((ref) => {
const url = ref.href ?? ref.path;
return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`;
})
.join('\n');
return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`;
}
function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
const plural = refs.length > 1;
const blocks = refs.map(buildCodeBlockFence).join('\n\n');
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
}
function buildCodeBlockFence(ref: CodeBlockReference): string {
const { label, content, syntax } = ref;
let max = 2;
for (const match of content.matchAll(/`+/g)) {
max = Math.max(max, match[0].length);
}
const fence = '`'.repeat(max + 1);
const heading = label ? `${label}\n` : '';
return `${heading}${fence}${syntax ?? ''}\n${content}\n${fence}`;
}
@@ -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 empty:hidden"
/>
{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>
@@ -71,13 +71,13 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
}
}
function DescriptionForPageContentToolCall(props: {
async function DescriptionForPageContentToolCall(props: {
toolCall: AIToolCallGetPageContent;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<p>
@@ -101,13 +101,13 @@ function DescriptionForPageContentToolCall(props: {
);
}
function DescriptionForMCPToolCall(props: {
async function DescriptionForMCPToolCall(props: {
toolCall: AIToolCallMCP;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<p>
@@ -135,7 +135,7 @@ async function DescriptionForSearchToolCall(props: {
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
// Resolve all hrefs for search results in parallel
const searchResultsWithHrefs = await Promise.all(
@@ -287,13 +287,13 @@ async function DescriptionForSearchToolCall(props: {
);
}
function DescriptionForGetPagesToolCall(props: {
async function DescriptionForGetPagesToolCall(props: {
toolCall: AIToolCallGetPages;
context: GitBookSiteContext;
}) {
const { toolCall, context } = props;
const language = getSpaceLanguage(context);
const language = await getSpaceLanguage(context);
return (
<p>
@@ -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';
@@ -36,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();
@@ -1,2 +1,3 @@
export * from './types';
export * from './chat';
export * from './navigate';
@@ -0,0 +1,48 @@
'use server';
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
import { toInSiteHref } from '../navigation';
/**
* Resolve a link provided by the assistant into a path that can be navigated to within the site.
*
* The assistant references pages using the stable content-ref scheme (e.g.
* `/spaces/<spaceId>/pages/<pageId>`). Those URLs are not directly navigable in the published
* site, so we resolve them to the real site link using the site context. Any other URL is only
* accepted if it points within the current site, so the assistant cannot navigate the reader off
* the documentation site.
*/
export async function resolveAINavigationLink(
url: string
): Promise<{ href: string } | { error: string }> {
return traceErrorOnly('AI.resolveAINavigationLink', async () => {
const baseContext = await getServerActionBaseContext();
const context = await fetchServerActionSiteContext(baseContext);
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
let path = url;
if (URL.canParse(url)) {
const parsed = new URL(url);
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
}
const contentRef = resolveStringContentRef(path);
if (contentRef) {
const resolved = await resolveContentRef(contentRef, context);
if (!resolved) {
return { error: `Could not resolve page for ${url}` };
}
return { href: resolved.href };
}
// Not a content reference: only navigate to it if it points within the current site.
const inSiteHref = toInSiteHref(url, context.linker);
if (!inSiteHref) {
return { error: 'Cannot navigate to a page outside of this documentation site.' };
}
return { href: inSiteHref };
});
}
+4 -2
View File
@@ -2,7 +2,9 @@ import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { integrationsAssistantTools } from '../Integrations';
import { type AnyAIControlTool, getControlTools } from './controls';
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
export function getTools(
builtInTools: GitBookIntegrationTool[] = []
): (GitBookIntegrationTool | AnyAIControlTool)[] {
const integrationTools = integrationsAssistantTools.getState().tools;
return [...getControlTools(), ...integrationTools];
return [...getControlTools(), ...builtInTools, ...integrationTools];
}
+2 -2
View File
@@ -63,7 +63,7 @@ export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): Rea
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
}
function useAIConfig(): AIConfig {
export function useAIConfig(): AIConfig {
const ctx = React.useContext(AIContext);
if (!ctx) {
throw new Error('useAI must be used within AIContextProvider');
@@ -95,7 +95,7 @@ export function useAI(): AIContext {
label: config.assistantName ?? getAIChatName(language, config.trademark),
icon: (
<AIChatIcon
state={chat.loading ? 'thinking' : 'default'}
state={chat.responding ? 'thinking' : 'default'}
trademark={config.trademark}
className="size-text-lg"
/>
+263 -26
View File
@@ -2,30 +2,53 @@
import * as zustand from 'zustand';
import { useCurrentContent } from '@/components/hooks';
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 { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type AIChatReference, serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
references?: AIChatReference[];
activity?: AIChatMessageActivity;
};
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.
@@ -63,7 +86,23 @@ export type AIChatState = {
control: AnyAIControl | null;
/**
* If true, the session is in progress.
* If true, the assistant is actively producing its answer — from the moment a
* message is sent until the `response_finish` event. It is cleared at that point
* (even though follow-up suggestions may still trickle in) so the input can be
* re-enabled. Drives the local "are we still answering" UI: the disabled input,
* the loading shim, and the thinking/exploring/working status.
*/
responding: boolean;
/**
* If true, the turn is still in progress overall: from the moment a message is
* sent until the stream fully completes, including the follow-up suggestion
* phase.
*
* Unlike `responding` — which clears on `response_finish` — this stays true
* until the response is truly settled. It is the global busyness indicator,
* surfaced as `aria-busy` on the chat so that assistive tech (and visual tests)
* can wait for a complete, stable response.
*/
loading: boolean;
@@ -73,13 +112,19 @@ export type AIChatState = {
* display an error alert. Clearing the conversation will reset this flag.
*/
error: boolean;
/**
* References staged on the next user message.
*/
references: AIChatReference[];
};
export type AIChatEvent =
| { type: 'open' }
| { type: 'postMessage'; message: string }
| { type: 'clear' }
| { type: 'close' };
| { type: 'close' }
| { type: 'focus' };
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
@@ -97,6 +142,14 @@ export type AIChatController = {
postMessage: (input: { message: string }) => void;
/** Clear the conversation */
clear: () => void;
/** Stage a reference on the next message */
addReference: (ref: AIChatReference) => string;
/** Remove a staged reference */
removeReference: (id: string) => void;
/** Clear all staged references */
clearReferences: () => void;
/** Focus the chat input */
focus: () => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
@@ -115,9 +168,11 @@ const globalState = zustand.create<AIChatState>(() => {
query: null,
followUpSuggestions: [],
control: null,
responding: false,
loading: false,
error: false,
initialQuery: null,
references: [],
};
});
@@ -152,8 +207,13 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
// identity, so it can be referenced directly from the streaming callback.
const navigateToPageTool = useNavigateToPageTool();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
@@ -193,6 +253,8 @@ export function AIChatProvider(props: {
async (input: {
/** Text message to send to the AI backend */
message?: string;
/** User-typed prompt; compared against state.query to abort stale streams */
userQuery?: string;
/** Tool call to send to the AI backend */
toolCall?: AIToolCallResult;
}) => {
@@ -201,6 +263,7 @@ export function AIChatProvider(props: {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
@@ -208,14 +271,25 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
});
// A stream becomes stale once a newer turn (or a clear) has replaced its
// query. Because `responding` clears on `response_finish` — before follow-up
// suggestions finish streaming — the user can start a new turn while this one
// is still wrapping up. A stale stream must not mutate the shared
// loading/responding state, which now belongs to the active turn; otherwise
// it would make the UI look idle mid-response. (`userQuery` is only set for
// user-initiated turns, not tool-call continuations.)
const isSuperseded = () =>
!!input.userQuery && globalState.getState().query !== input.userQuery;
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools();
const tools = getTools([navigateToPageTool]);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
@@ -251,16 +325,13 @@ export function AIChatProvider(props: {
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools();
const tools = getTools([navigateToPageTool]);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
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,
@@ -278,8 +349,8 @@ export function AIChatProvider(props: {
for await (const data of stream) {
if (!data) continue;
if (input.message && globalState.getState().query !== input.message) {
// Chat was cleared, stop processing the stream
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
@@ -290,9 +361,9 @@ export function AIChatProvider(props: {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not loading when the response is finished
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
loading: false,
responding: false,
error: false,
}));
break;
@@ -400,28 +471,50 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
}
// Execute the tool call if it doesn't require confirmation
// If a newer turn replaced this one while we were finishing (e.g.
// streaming follow-up suggestions after `response_finish`), abandon this
// stale stream without executing leftover tools or clearing the shared
// loading/responding state, which now belongs to the active turn.
if (isSuperseded()) {
return;
}
// Execute the tool call if it doesn't require confirmation.
// When a tool call (or control) keeps the turn going, `loading`
// stays true: either the recursive `streamResponse` will clear it
// when its stream settles, or it is cleared below once the loop ends
// (e.g. while waiting on a user confirmation control).
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: false,
}));
}
} catch (error) {
console.error('Error streaming AI response', error);
globalState.setState((state) => ({
...state,
loading: false,
error: true,
}));
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
@@ -430,20 +523,32 @@ export function AIChatProvider(props: {
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
]
);
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control } = globalState.getState();
const { query, messages, control, references, responding } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Ignore duplicates while a previous turn is still streaming
if (responding) {
return;
}
const wireMessage = `${serializeReferences(references)}${input.message}`;
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
@@ -454,8 +559,9 @@ export function AIChatProvider(props: {
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message) {
if (query === input.message && references.length === 0) {
// Return early if the message is the same as the previous message
// (unless new references are staged, which change the payload)
globalState.setState((state) => ({
...state,
opened: true,
@@ -475,25 +581,28 @@ export function AIChatProvider(props: {
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
loading: true,
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: input.message });
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, trackEvent, streamResponse, language]
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
@@ -502,6 +611,7 @@ export function AIChatProvider(props: {
responseId: null,
error: false,
initialQuery: null,
references: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
@@ -513,6 +623,44 @@ export function AIChatProvider(props: {
}));
}, [setSearchState]);
const onAddReference = React.useCallback((ref: AIChatReference) => {
globalState.setState((state) => {
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
return state;
}
return {
...state,
references: [...state.references, ref],
};
});
return ref.id;
}, []);
const onRemoveReference = React.useCallback((id: string) => {
globalState.setState((state) => {
if (!state.references.some((ref) => ref.id === id)) {
return state;
}
return {
...state,
references: state.references.filter((ref) => ref.id !== id),
};
});
}, []);
const onClearReferences = React.useCallback(() => {
globalState.setState((state) => {
if (state.references.length === 0) {
return state;
}
return { ...state, references: [] };
});
}, []);
const onFocus = React.useCallback(() => {
notify(eventsRef.current.get('focus'), {});
}, []);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
@@ -538,9 +686,23 @@ export function AIChatProvider(props: {
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
on: onEvent,
};
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onEvent,
]);
return (
<AIChatControllerContext.Provider value={controller}>
@@ -560,3 +722,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.responding) {
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,
};
}
@@ -0,0 +1,117 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import type { AIToolDefinition } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { NavigationStatusContext } from '../hooks';
import { normalizePathname, resolveNavigationTarget } from './navigation';
import { resolveAINavigationLink } from './server-actions';
const NavigateToPageInputSchema = z.object({
url: z
.string()
.describe(
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
),
});
/**
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
* after a timeout. App Router updates `window.location` only when the navigation commits, so this
* lets the tool hold its turn until the user is actually on the new page — after which the
* tool-result server action's router refresh can no longer cancel the navigation.
*/
function waitForNavigationCommit(pathname: string): Promise<boolean> {
const target = normalizePathname(pathname);
if (normalizePathname(window.location.pathname) === target) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const startedAt = Date.now();
const check = () => {
if (normalizePathname(window.location.pathname) === target) {
resolve(true);
} else if (Date.now() - startedAt > 3000) {
resolve(false);
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
});
}
/**
* Build the built-in `navigateToPage` tool exposed to the assistant.
*
* The tool opens a page within the current documentation site without confirmation. It navigates
* instantly with the Next.js router (adding a browser history entry, so the user can navigate
* back) and waits for the navigation to commit before reporting back, so the assistant's
* follow-up does not cancel the navigation.
*/
export function useNavigateToPageTool(): GitBookIntegrationTool {
const router = useRouter();
const language = useLanguage();
const { onNavigationClick } = React.useContext(NavigationStatusContext);
// The tool object is memoized once, so read the latest values from a ref at call time.
const ref = React.useRef({ router, language, onNavigationClick });
React.useEffect(() => {
ref.current = { router, language, onNavigationClick };
});
return React.useMemo<GitBookIntegrationTool>(
() => ({
name: 'navigateToPage',
description:
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
inputSchema: zodToJsonSchema(
NavigateToPageInputSchema as any
) as AIToolDefinition['inputSchema'],
execute: async (input) => {
const { router, language, onNavigationClick } = ref.current;
const { url } = NavigateToPageInputSchema.parse(input);
// The assistant references pages using the stable content-ref scheme
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
const resolved = await resolveAINavigationLink(url);
const target =
'error' in resolved
? resolved
: resolveNavigationTarget(resolved.href, window.location);
if ('error' in target) {
return {
output: { error: target.error },
summary: {
icon: 'triangle-exclamation',
text: tString(language, 'ai_chat_tools_navigate_failed'),
},
};
}
onNavigationClick(target.href);
router.push(target.href);
const committed = await waitForNavigationCommit(target.pathname);
if (!committed) {
// biome-ignore lint/suspicious/noConsole: surfaces a navigation that never committed
console.warn(`navigateToPage: navigation to ${target.href} did not commit`);
}
return {
output: { navigated: true, url: target.href },
summary: {
icon: 'book-open',
text: tString(language, 'ai_chat_tools_navigated_to_page'),
},
};
},
}),
[]
);
}
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
getAIChatStatus,
useAI,
useAIChatController,
useAIChatState,
@@ -82,12 +83,13 @@ export function AIChat() {
}
}}
withOverlay={true}
data-ai-chat
className={tcls(
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
)}
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
<EmbeddableFrameHeader className="not-embed:px-4">
<AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain>
@@ -107,7 +109,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 +132,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,59 +151,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 || chat.control ? '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
{(
[
['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'
: 'hidden'
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay:
chat.messages[chat.messages.length - 1]?.content && !chat.control
? '.3s'
: undefined,
}}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-display-slow'
: 'animate-blur-in-display-slow'
: 'hidden'
)}
style={{
animationDelay:
chat.messages[chat.messages.length - 1]?.content || chat.control
? undefined
: '.3s',
}}
>
{t(language, 'ai_chat_thinking')}
</span>
<span
className={tcls(
'absolute left-0',
chat.control ? 'animate-blur-in-display-slow' : 'animate-blur-out-display-slow'
)}
style={{ animationDelay: chat.control ? '.3s' : undefined }}
>
{t(language, 'ai_chat_waiting')}
</span>
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay: status === candidateStatus ? '.3s' : undefined,
}}
>
{subtitleKey ? t(language, key) : null}
</span>
))}
</EmbeddableFrameSubtitle>
);
}
@@ -249,8 +226,8 @@ export function AIChatBody(props: {
return (
<>
<ScrollContainer
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 [scroll-behavior:smooth]"
className="min-h-[20%] max-w-full shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
@@ -298,14 +275,14 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex max-h-3/4 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} /> : null}
<AIChatInput
loading={chat.loading}
disabled={chat.loading || chat.error}
responding={chat.responding}
disabled={chat.responding || chat.error}
onSubmit={(value) => {
chatController.postMessage({ message: value });
}}
@@ -1,12 +1,12 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { t, tString } from '@/intl/translate';
import type { Assistant } from '../AI';
import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
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.
@@ -38,6 +38,7 @@ export function AIChatButton(props: {
) : null}
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
@@ -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: '',
@@ -3,27 +3,29 @@ import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatState } from '../AI/useAIChat';
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { Input } from '../primitives/Input';
import { AIChatReferenceChips } from './AIChatReferenceChips';
export function AIChatInput(props: {
disabled?: boolean;
/**
* When true, the input is disabled
*/
loading: boolean;
responding: boolean;
onSubmit: (value: string) => void;
}) {
const { onSubmit, disabled, loading } = props;
const { onSubmit, disabled, responding } = props;
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (chat.opened && !disabled && !loading) {
if (chat.opened && !disabled && !responding) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
@@ -32,7 +34,14 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout);
}
}, [disabled, loading, chat.opened]);
}, [disabled, responding, chat.opened]);
// Explicit focus requests (e.g. clicking "Ask" while the chat is already open).
useEffect(() => {
return chatController.on('focus', () => {
inputRef.current?.focus();
});
}, [chatController]);
useHotkeys(
'mod+i',
@@ -66,16 +75,23 @@ export function AIChatInput(props: {
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !loading
!disabled && !responding
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || loading || chat.control !== null}
aria-busy={loading}
disabled={disabled || responding || chat.control !== null}
aria-busy={responding}
ref={inputRef}
header={
<AIChatReferenceChips
references={chat.references}
onRemove={chatController.removeReference}
disabled={responding || disabled}
/>
}
trailing={
<HoverCardRoot openDelay={500}>
<HoverCard
@@ -1,8 +1,18 @@
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 { Fragment } from 'react';
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 { AIChatReferenceChips } from './AIChatReferenceChips';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -11,10 +21,11 @@ export function AIChatMessages(props: {
chatController: AIChatController;
}) {
const { chat, chatController } = props;
const status = getAIChatStatus(chat);
const showLoadingShim = chat.responding && status !== 'working' && status !== 'done';
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type 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,8 +47,33 @@ export function AIChatMessages(props: {
messageGroups.push(currentGroup);
}
const language = useLanguage();
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
const userItems = group.filter(({ message }) => message.role === AIMessageRole.User);
const assistantItems = group.filter(
({ message }) => message.role === AIMessageRole.Assistant
);
// Each client-side tool call splits a turn into a separate assistant message, so without
// aggregating here every tool would render its own "Explored with 1 tool" expandable.
// We collapse the whole assistant run behind a single expandable instead, reusing the
// commentary (hidden) vs final-answer (visible) distinction from AIMessageView.
const toolCount = assistantItems.reduce(
(acc, { message }) => acc + (message.activity?.toolCount ?? 0),
0
);
const hasCommentary =
assistantItems.some(({ message }) => message.activity?.hasCommentary) || toolCount > 0;
const hasFinalAnswer =
assistantItems.some(({ message }) => message.activity?.hasFinalAnswer) || !isLastGroup;
const firstAssistantIndex = assistantItems.at(0)?.originalIndex ?? -1;
const lastAssistantIndex = assistantItems.at(-1)?.originalIndex ?? -1;
const isLastMessage = lastAssistantIndex === chat.messages.length - 1;
return (
<div
key={groupIndex}
@@ -50,69 +86,125 @@ export function AIChatMessages(props: {
)}
style={{ animationDelay: '.2s' }}
>
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
return (
{userItems.map(({ message, originalIndex }) => (
<Fragment key={originalIndex}>
{message.references?.length ? (
<div className="flex max-w-[80%] origin-top-right justify-end self-end">
<AIChatReferenceChips references={message.references} />
</div>
) : null}
<div
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
? 'ai-chat-message-user'
: 'ai-chat-message-assistant'
}
data-testid="ai-chat-message-user"
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',
isLastMessage && message.role === AIMessageRole.Assistant
? 'grow'
: ''
'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
)}
style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{message.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'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
<LoadingSkeleton />
</div>
) : null}
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
!chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</div>
);
})}
</Fragment>
))}
{assistantItems.length > 0 ? (
<Collapsible
open={!hasFinalAnswer}
disabled={!hasFinalAnswer}
data-testid="ai-chat-message-assistant"
id={`message-${firstAssistantIndex}`}
className={tcls(
'flex flex-col gap-2',
'break-words',
'group/message',
'animate-blur-in-slow',
'origin-top-left text-tint-strong',
isLastMessage ? 'grow' : ''
)}
style={{
animationDelay: `${Math.min(firstAssistantIndex * 0.1, 0.6)}s`,
}}
>
{hasCommentary && hasFinalAnswer ? (
<CollapsibleTrigger asChild>
<Button
variant="blank"
size="small"
label={tString(language, 'ai_chat_view_activity')}
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
>
<div className="flex items-center gap-2">
<span data-testid="ai-chat-activity-summary">
{toolCount > 0
? t(
language,
'ai_chat_explored_with',
tString(
language,
toolCount === 1
? 'tool_count'
: 'tool_count_plural',
toolCount.toString()
)
)
: t(language, 'ai_chat_explored')}
</span>
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
{assistantItems.map(({ message, originalIndex }) => (
<Fragment key={originalIndex}>{message.content}</Fragment>
))}
{isLastMessage ? (
<div
className={tcls(
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
showLoadingShim
? 'max-h-48 opacity-11'
: 'pointer-events-none max-h-0 opacity-0'
)}
>
<HoldMessage
className={
assistantItems.some(({ message }) => message.content)
? 'hidden'
: ''
}
/>
<LoadingSkeleton />
</div>
) : null}
{isLastMessage ? (
<>
{!chat.responding &&
!chat.error &&
chat.query &&
chat.responseId &&
!chat.control ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1.5 -mt-4 mb-2"
/>
) : null}
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</Collapsible>
) : null}
</div>
);
});
@@ -0,0 +1,120 @@
'use client';
import { Icon, type IconName } from '@gitbook/icons';
import assertNever from 'assert-never';
import { tcls } from '@/lib/tailwind';
import type { AIChatReference } from '../AI/references';
import { Link } from '../primitives';
const triggerClassName =
'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint';
export function AIChatReferenceChips(props: {
references: AIChatReference[];
onRemove?: (id: string) => void;
disabled?: boolean;
}) {
const { references, onRemove, disabled } = props;
if (references.length === 0) {
return null;
}
return (
<div className="flex max-w-full flex-wrap gap-1.5">
{references.map((ref) => {
const content = (
<>
<Icon icon={getReferenceIcon(ref)} className="size-3 shrink-0 opacity-7" />
<span
className={tcls(
'min-w-0 truncate',
ref.type === 'code-block' && 'font-mono'
)}
>
{ref.label}
</span>
</>
);
return (
<div
key={ref.id}
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
>
{ref.type === 'page' && ref.href ? (
// A page reference may be clicked from anywhere: render a link so it
// navigates back to the page (and supports cmd/ctrl-click to open in a
// new tab).
<Link
href={ref.href}
prefetch={false}
className={triggerClassName}
onClick={(event) => event.stopPropagation()}
>
{content}
</Link>
) : (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
focusReference(ref);
}}
className={triggerClassName}
>
{content}
</button>
)}
{onRemove ? (
<button
type="button"
aria-label="Remove"
onClick={(event) => {
event.stopPropagation();
onRemove(ref.id);
}}
disabled={disabled}
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
>
<Icon icon="xmark" className="size-2.5" />
</button>
) : null}
</div>
);
})}
</div>
);
}
function getReferenceIcon(ref: AIChatReference): IconName {
switch (ref.type) {
case 'code-block':
return 'code';
case 'page':
return 'memo';
default:
assertNever(ref);
}
}
/**
* Jump to the content a reference points at, for chips that aren't rendered as links:
* - a code block: scroll it into view on the current page and focus it;
* - a page without a known href: scroll back to the top (the reader is most likely on it).
*/
function focusReference(ref: AIChatReference) {
if (ref.type === 'page') {
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const candidates = document.querySelectorAll<HTMLElement>(`#${CSS.escape(ref.id)}`);
const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]'));
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.querySelector<HTMLElement>('[data-codeblock-focus]')?.focus({ preventScroll: true });
}
@@ -1,5 +1,7 @@
import { useCurrentContent } from '@/components/hooks';
import { tString, useLanguage } from '@/intl/client';
import type { AIChatController } from '../AI';
import { useRecentSearchQueries } from '../Search/recent-queries';
import { Button } from '../primitives';
export default function AIChatSuggestedQuestions(props: {
@@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: {
suggestions?: string[];
}) {
const language = useLanguage();
const { chatController, suggestions: _suggestions } = props;
const { siteSpaceId } = useCurrentContent();
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
const { chatController, suggestions: configuredSuggestions } = props;
const suggestions =
_suggestions && _suggestions.length > 0
? _suggestions
: [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
const defaultSuggestions = [
tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'),
];
const baseSuggestions =
configuredSuggestions && configuredSuggestions.length > 0
? configuredSuggestions
: defaultSuggestions;
const suggestions = [
...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query),
...baseSuggestions,
].reduce<string[]>((acc, suggestion) => {
if (acc.includes(suggestion)) {
return acc;
}
acc.push(suggestion);
return acc;
}, []);
return (
<div
@@ -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.
+1 -1
View File
@@ -132,7 +132,7 @@ function AdSponsoredLink(props: { spaceId: string }) {
viaUrl.searchParams.set('utm_campaign', spaceId);
return (
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint')}>
<Link
target="_blank"
href={viaUrl.toString()}
@@ -18,7 +18,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
arrowProps={{ className: 'fill-tint-1' }}
>
<dfn
data-testid="annotation-button"
data-annotation=""
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
tabIndex={0}
@@ -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}
@@ -3,6 +3,7 @@ import type {
DocumentBlockEmbed,
DocumentBlockFile,
DocumentBlockImage,
DocumentBlockImages,
JSONDocument,
} from '@gitbook/api';
@@ -12,6 +13,14 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentContextProps } from './DocumentView';
import { Inlines } from './Inlines';
export type CaptionAlign = NonNullable<DocumentBlockImages['data']['align']>;
const captionAlignStyles = {
center: 'mx-auto',
left: 'mr-auto ml-0',
right: 'mr-0 ml-auto',
} satisfies Record<CaptionAlign, ClassValue>;
/**
* Wrap a content of a block that has a potential caption.
*/
@@ -22,6 +31,7 @@ export function Caption(
style?: ClassValue;
fit?: boolean;
wrapperStyle?: ClassValue;
align?: CaptionAlign;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
withFrame?: boolean;
@@ -49,6 +59,7 @@ export function Caption(
withFrame && 'p-2',
],
style,
align = 'center',
} = props;
const caption = getNodeFragmentByName(block, 'caption');
@@ -64,7 +75,7 @@ export function Caption(
return (
<picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<div className={tcls(wrapperStyle, captionAlignStyles[align])}>{children}</div>
<figcaption
className={tcls(
'text-xs',
@@ -0,0 +1,57 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { AIChatIcon } from '@/components/AIChat';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getCodeTextFromId } from './utils';
export function AskAICodeButton(props: {
codeId: string;
title?: string;
syntax?: string;
style: ClassValue;
}) {
const { codeId, title, syntax, style } = props;
const language = useLanguage();
const config = useAIConfig();
const chatController = useAIChatController();
if (config.aiMode !== CustomizationAIMode.Assistant) {
return null;
}
const onClick = () => {
const codeText = getCodeTextFromId(codeId);
if (!codeText?.trim()) {
return;
}
chatController.addReference({
type: 'code-block',
id: codeId,
label: title ?? 'Code',
content: codeText,
syntax,
});
chatController.open();
chatController.focus();
};
return (
<Button
size="xsmall"
variant="secondary"
icon={<AIChatIcon state="default" trademark={config.trademark} className="" />}
onClick={onClick}
className={tcls(style, 'translate-y-0!', 'print:hidden', '[[data-ai-chat]_&]:hidden')}
>
{t(language, 'ask')}
</Button>
);
}
@@ -141,6 +141,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
style={style}
theme={theme ?? plainTheme}
id={codeBlockBodyId}
isPrint={mode === 'print'}
/>
);
@@ -81,7 +81,14 @@ export async function CodeBlock(
const theme = await highlight(block, richInlines, {
themes: themes,
});
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
return (
<CodeBlockRenderer
block={block}
style={style}
theme={theme}
isPrint={context.mode === 'print'}
/>
);
}
const variables = context.contentContext
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import { AskAICodeButton } from './AskAICodeButton';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
@@ -13,6 +14,11 @@ type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'sty
theme: HighlightTheme;
'aria-busy'?: boolean;
id?: string;
/**
* Whether the block is rendered for print/PDF.
* When true, the "Ask AI" button is not rendered.
*/
isPrint?: boolean;
};
/**
@@ -22,7 +28,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -39,15 +45,24 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
return (
<div
ref={ref}
id={codeId}
aria-busy={ariaBusy}
className={tcls(
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
style
)}
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
>
<span
data-codeblock-focus
tabIndex={-1}
aria-hidden
className="pointer-events-none absolute size-0 outline-none"
/>
<div className="flex items-center justify-start gap-2 text-sm">
{title ? (
<div
@@ -63,11 +78,18 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
</div>
) : null}
</div>
<div className="relative">
<CopyCodeButton
codeId={codeId}
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
/>
<div className="relative flex min-h-0 flex-col">
<div className="absolute top-2 right-2 z-2 flex items-start gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11">
{!isPrint ? (
<AskAICodeButton
codeId={codeId}
title={title}
syntax={block.data.syntax}
style="backdrop-blur-md"
/>
) : null}
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
</div>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
@@ -82,7 +104,6 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
}}
>
<code
id={codeId}
className={tcls(
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
@@ -114,27 +135,30 @@ function CodeHighlightLine(props: {
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers, bg, fg } = props;
const lineStyle = {
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
};
return (
<span
className={tcls('highlight-line', line.highlighted && 'highlighted')}
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
>
{withLineNumbers && (
<span
className="highlight-line-number"
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
/>
className={tcls(
'highlight-line',
line.diff === 'added' && 'diff-added',
line.diff === 'deleted' && 'diff-deleted',
line.highlighted && 'highlighted'
)}
aria-label={
line.diff === 'added'
? 'Added line'
: line.diff === 'deleted'
? 'Removed line'
: undefined
}
style={lineStyle}
>
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -6,6 +6,8 @@ import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { getCodeTextFromId } from './utils';
/**
* Client component to copy the code of a code block.
* To avoid passing large payload to the client, the code is computed from the DOM.
@@ -31,12 +33,12 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
}, [copied]);
const onClick = () => {
const element = document.getElementById(codeId);
if (!element) {
const codeText = getCodeTextFromId(codeId);
if (codeText === null) {
return;
}
navigator.clipboard.writeText(getCodeText(element));
navigator.clipboard.writeText(codeText);
setCopied(true);
};
@@ -52,31 +54,3 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
</Button>
);
}
/**
* Compute the code text from the DOM,
* ignoring the empty white space we use for empty lines (represented with a class "ew").
*/
function getCodeText(code: HTMLElement): string {
let text = '';
const iterate = (node: Node) => {
if (node instanceof HTMLBRElement) {
text += '\n';
} else if (node instanceof HTMLSpanElement) {
if (node.classList.contains('ew')) {
text += '\n';
} else {
text += node.innerText;
}
} else if (node instanceof HTMLElement) {
node.childNodes.forEach(iterate);
} else {
text += node.textContent;
}
};
iterate(code);
return text;
}
@@ -1,31 +1,62 @@
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import type { RenderResult } from 'mermaid';
import { FocusScope, usePreventScroll } from 'react-aria';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';
/** Duration of the fullscreen dialog enter/exit animation, must match `animate-blur-in/out`. */
const DIALOG_ANIMATION_MS = 200;
/**
* Used to render a Mermaid diagram from a CodeBlock.
*/
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, style } = props;
const { block, mode, style } = props;
const source = getPlainCodeBlock(block);
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
// A stable container that holds the diagram subtree. We portal the diagram into it and
// only ever move this plain node between the inline slot and the dialog — never the
// React-managed subtree itself — so React stays in control and panzoom/SVG are preserved.
const diagramHostRef = useRef<HTMLDivElement | null>(null);
if (diagramHostRef.current === null && typeof document !== 'undefined') {
const host = document.createElement('div');
// `display: contents` so the host adds no box of its own (the diagram becomes a
// direct flex child of the dialog panel and can fill it).
host.style.display = 'contents';
diagramHostRef.current = host;
}
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// `isFullscreen` is the open intent; `isExiting` keeps the dialog mounted while it
// animates closed. `isPresent` is true whenever the diagram lives in the dialog.
const [isFullscreen, setIsFullscreen] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const isPresent = isFullscreen || isExiting;
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
const shouldRender = mode === 'print' || hasBeenInViewport;
useEffect(() => {
if (!shouldRender) {
return;
}
const container = diagramRef.current;
const wrapper = wrapperRef.current;
if (!container || !wrapper) {
@@ -36,57 +67,165 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
let cleanupPanZoom: (() => void) | undefined;
setError(false);
setIsLoading(true);
setPanZoom(null);
container.innerHTML = '';
const cancelScheduledRender = scheduleMermaidWork(() => {
enqueueMermaidRender(async () => {
if (cancelled) {
return null;
}
return renderMermaidDiagram({
source,
id,
darkMode,
});
})
.then((result) => {
if (!result || cancelled) {
return;
}
container.innerHTML = result.svg;
if (container.querySelector('svg')) {
result.bindFunctions?.(container);
}
renderMermaidDiagram({
container,
source,
id,
darkMode,
})
.then(() => {
if (!cancelled) {
cleanupPanZoom = initPanzoom({
container,
wrapper,
onInit: setPanZoom,
});
}
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
});
return () => {
cancelled = true;
cancelScheduledRender();
cleanupPanZoom?.();
setPanZoom(null);
};
}, [source, id, darkMode]);
}, [source, id, darkMode, shouldRender]);
// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
usePreventScroll({ isDisabled: !isPresent });
const openFullscreen = useCallback(() => {
// Reserve the inline slot's current height before the diagram is detached, so the
// page layout does not jump. Measured here while still inline and un-restyled.
const root = rootRef.current;
if (root) {
root.style.minHeight = `${root.offsetHeight}px`;
}
setIsExiting(false);
setIsFullscreen(true);
// Re-center the diagram for the larger view.
panZoom?.reset();
}, [panZoom]);
const closeFullscreen = useCallback(() => {
setIsFullscreen(false);
setIsExiting(true);
}, []);
// Keep the dialog mounted until the exit animation finishes, then unmount it.
useEffect(() => {
if (!isExiting) {
return;
}
const timer = window.setTimeout(() => {
setIsExiting(false);
panZoom?.reset();
}, DIALOG_ANIMATION_MS);
return () => window.clearTimeout(timer);
}, [isExiting, panZoom]);
// Allow Escape to close the dialog.
useEffect(() => {
if (!isFullscreen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeFullscreen();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isFullscreen, closeFullscreen]);
// Keep the diagram host in the inline slot on mount (and whenever it isn't in the dialog).
useLayoutEffect(() => {
const host = diagramHostRef.current;
const root = rootRef.current;
if (host && root && !host.parentNode) {
root.appendChild(host);
}
}, []);
// Move the diagram host into the dialog panel (and back) as the panel mounts/unmounts.
// Done in the panel's ref callback so it happens during commit, before FocusScope reads
// focus. The inline slot's reserved height (set in openFullscreen) is cleared on return.
const setPanel = useCallback((panel: HTMLDivElement | null) => {
panelRef.current = panel;
const host = diagramHostRef.current;
const root = rootRef.current;
if (!host) {
return;
}
if (panel) {
panel.appendChild(host);
} else if (root) {
root.appendChild(host);
root.style.minHeight = '';
}
}, []);
if (error) {
return <ClientCodeBlock {...props} />;
}
return (
<div className={tcls('relative', style)} contentEditable={false}>
// The live diagram subtree. It is portaled into a stable host that moves between the
// inline slot and the dialog, so its markup must not depend on where it currently lives.
const diagram = (
<div
className={tcls(
'group/mermaid relative',
isPresent ? 'flex h-full w-full flex-col' : null
)}
>
<div
ref={wrapperRef}
className={
className={tcls(
isLoading
? 'invisible absolute inset-x-0 overflow-hidden'
: 'cursor-grab overflow-hidden active:cursor-grabbing'
}
: 'cursor-grab overflow-hidden active:cursor-grabbing',
isPresent && !isLoading ? 'flex-1' : null
)}
>
<div
ref={diagramRef}
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
className={tcls(
'overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full',
isPresent
? 'flex h-full items-center justify-center [&_svg]:max-h-full'
: null
)}
/>
</div>
{isLoading ? (
@@ -94,24 +233,62 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
<Loading className="h-8 w-8" />
</div>
) : null}
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
{!isLoading && panZoom ? (
<MermaidPanZoomControls
panZoom={panZoom}
isFullscreen={isPresent}
onToggleFullscreen={isFullscreen ? closeFullscreen : openFullscreen}
/>
) : null}
</div>
);
return (
<>
{/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */}
<div ref={rootRef} className={tcls('relative', style)} contentEditable={false} />
{diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null}
{isPresent
? createPortal(
<FocusScope contain restoreFocus>
{/* Backdrop: dims and blurs the page, closes on click. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */}
<div
aria-hidden="true"
className={tcls(
'fixed inset-0 z-40 bg-tint-base/3 backdrop-blur-md dark:bg-tint-base/6',
isFullscreen ? 'animate-fade-in' : 'animate-fade-out'
)}
onClick={closeFullscreen}
/>
{/* Centered panel. The padding area lets clicks fall through to the backdrop. */}
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center p-3 sm:p-5 lg:p-8">
<div
ref={setPanel}
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram"
className={tcls(
'pointer-events-auto relative flex h-full w-full max-w-[110rem] flex-col overflow-hidden rounded-2xl border border-tint-subtle bg-tint-base shadow-2xl',
isFullscreen ? 'animate-blur-in' : 'animate-blur-out'
)}
/>
</div>
</FocusScope>,
document.body
)
: null}
</>
);
}
async function renderMermaidDiagram(args: {
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,
@@ -120,11 +297,115 @@ 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.
*/
@@ -138,7 +419,6 @@ function initPanzoom(args: {
const instance = Panzoom(container, {
maxScale: 5,
minScale: 0.5,
contain: 'outside',
cursor: 'grab',
panOnlyWhenZoomed: true,
});
@@ -3,77 +3,54 @@
import type { PanzoomObject } from '@panzoom/panzoom';
import { Button } from '@/components/primitives';
const PAN_STEP = 50;
import { tcls } from '@/lib/tailwind';
/**
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
* Zoom and fullscreen controls for mermaid diagrams, grouped into a single vertical
* toolbar positioned in the bottom-right corner. Panning is done by dragging the diagram.
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
export function MermaidPanZoomControls(props: {
panZoom: PanzoomObject;
isFullscreen: boolean;
onToggleFullscreen: () => void;
}) {
const { panZoom, isFullscreen, onToggleFullscreen } = props;
const btnProps = {
variant: 'secondary' as const,
size: 'xsmall' as const,
iconOnly: true,
className: tcls('p-1 [&_svg]:size-3.5 opacity-90'),
// Non-interactive tooltips: the Tooltip primitive makes their popper wrapper
// pointer-transparent so it can't steal the hover that reveals these controls.
tooltipProps: {
rootProps: { disableHoverableContent: true },
},
};
return (
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
{/* Row 1: empty, pan up, zoom in */}
<div />
<Button
label="Pan up"
icon="chevron-up"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
/>
<Button
label="Zoom in"
icon="plus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomIn()}
/>
{/* Row 2: pan left, reset, pan right */}
<Button
label="Pan left"
icon="chevron-left"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<div
className={tcls(
'absolute right-2 bottom-2 z-10 flex flex-col items-center gap-0.5 rounded-lg transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none',
// Keep the controls always visible in fullscreen, otherwise only reveal on hover/focus.
isFullscreen ? 'opacity-100' : 'opacity-0'
)}
>
<Button {...btnProps} icon="plus" label="Zoom in" onClick={() => panZoom.zoomIn()} />
<Button
{...btnProps}
icon="arrows-to-dot"
label="Reset view"
icon="refresh"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.reset()}
/>
<Button {...btnProps} icon="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />
<div className="my-0.5 h-px w-4 bg-tint-subtle" />
<Button
label="Pan right"
icon="chevron-right"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button
label="Pan down"
icon="chevron-down"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button
label="Zoom out"
icon="minus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomOut()}
{...btnProps}
icon={isFullscreen ? 'compress' : 'expand'}
label={isFullscreen ? 'Exit full page' : 'View in full page'}
onClick={onToggleFullscreen}
/>
</div>
);
@@ -5,6 +5,7 @@ import type {
} from '@gitbook/api';
import { useId } from 'react';
import type { DocumentContext } from '../DocumentView';
import { CodeBlock } from './CodeBlock';
import { convertCodeStringToBlock } from './utils';
@@ -15,10 +16,11 @@ import { convertCodeStringToBlock } from './utils';
export function PlainCodeBlock(props: {
code: string;
syntax: string;
mode?: DocumentContext['mode'];
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, themeKey, themes } = props;
const { code, syntax, mode = 'default', themeKey, themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -33,7 +35,7 @@ export function PlainCodeBlock(props: {
<CodeBlock
document={document}
context={{
mode: 'default',
mode,
}}
themeKey={themeKey}
themes={themes}
@@ -1,7 +1,13 @@
import { expect, it } from 'bun:test';
import type { DocumentBlockCode } from '@gitbook/api';
import { type RenderedInline, getInlines, highlight } from './highlight';
import {
type HighlightLine,
type HighlightToken,
type RenderedInline,
getInlines,
highlight,
} from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
@@ -690,6 +696,138 @@ it('should support multiple code tokens in an annotation', async () => {
]);
});
function joinLineContent(line: HighlightLine): string {
const visit = (tokens: HighlightToken[]): string =>
tokens
.map((t) => {
if (t.type === 'plain') return t.content;
if (t.type === 'shiki') return t.token.content;
return visit(t.children);
})
.join('');
return visit(line.tokens);
}
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
return {
object: 'block',
type: 'code',
data: syntax ? { syntax } : {},
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text }],
},
],
},
],
};
}
it('classifies and strips trailing // [!code ++] in JS', async () => {
const lines = await highlightWithInlines(
singleLineBlock('javascript', 'const a = 1 // [!code ++]')
);
expect(lines).toHaveLength(1);
expect(lines[0]!.diff).toBe('added');
expect(joinLineContent(lines[0]!)).toBe('const a = 1');
});
it('classifies and strips trailing # [!code --] in Python', async () => {
const lines = await highlightWithInlines(singleLineBlock('python', 'x = 1 # [!code --]'));
expect(lines[0]!.diff).toBe('deleted');
expect(joinLineContent(lines[0]!)).toBe('x = 1');
});
it('classifies and strips <!-- [!code ++] --> in HTML', async () => {
const lines = await highlightWithInlines(
singleLineBlock('html', '<div></div> <!-- [!code ++] -->')
);
expect(lines[0]!.diff).toBe('added');
expect(joinLineContent(lines[0]!)).toBe('<div></div>');
});
it('classifies and strips /* [!code --] */ in CSS', async () => {
const lines = await highlightWithInlines(
singleLineBlock('css', '.a { color: red; } /* [!code --] */')
);
expect(lines[0]!.diff).toBe('deleted');
expect(joinLineContent(lines[0]!)).toBe('.a { color: red; }');
});
it('does not classify when marker is not at end of line', async () => {
const lines = await highlightWithInlines(
singleLineBlock('javascript', 'const x = 1 // [!code ++] trailing')
);
expect(lines[0]!.diff).toBeNull();
expect(joinLineContent(lines[0]!)).toBe('const x = 1 // [!code ++] trailing');
});
it('returns diff: null for lines without a marker', async () => {
const lines = await highlightWithInlines(singleLineBlock('javascript', 'console.log("hi")'));
expect(lines[0]!.diff).toBeNull();
});
it('classifies and strips marker via plainHighlighting fallback', async () => {
const lines = await highlightWithInlines(
singleLineBlock(undefined, 'plain text // [!code ++]')
);
expect(lines[0]!.diff).toBe('added');
expect(joinLineContent(lines[0]!)).toBe('plain text');
});
it('preserves inline annotation when marker is stripped', async () => {
const tokens = await highlightWithInlines({
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'console.' }],
},
{
object: 'inline',
type: 'annotation',
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'log' }],
},
],
isVoid: false,
fragments: [],
},
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: '("Hi") // [!code ++]' }],
},
],
},
],
});
expect(tokens[0]!.diff).toBe('added');
expect(joinLineContent(tokens[0]!)).toBe('console.log("Hi")');
// inline annotation around "log" must be preserved
const hasAnnotation = tokens[0]!.tokens.some(
(t) =>
t.type === 'annotation' &&
t.children.some((c) => c.type === 'shiki' && c.token.content === 'log')
);
expect(hasAnnotation).toBe(true);
});
it('should handle \\r', async () => {
const tokens = await highlightWithInlines({
object: 'block',
@@ -34,11 +34,114 @@ export type HighlightTheme = {
lines: HighlightLine[];
};
export type LineDiffNotation = 'added' | 'deleted';
export type HighlightLine = {
highlighted: boolean;
diff: LineDiffNotation | null;
tokens: HighlightToken[];
};
/**
* Detects an in-source diff notation marker at the end of a line, e.g.
* `// [!code ++]`, `# [!code --]`, `<!-- [!code ++] -->`, `/* [!code --] *​/`.
* Mirror of the gitbook-x parser; keep regex byte-for-byte identical.
*/
const NOTATION_PATTERN =
/[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\s+(\+\+|--)\]|<!--\s*\[!code\s+(\+\+|--)\]\s*-->|\/\*\s*\[!code\s+(\+\+|--)\]\s*\*\/)\s*$/;
export function parseDiffNotation(
line: string
): { diff: LineDiffNotation; markerStart: number } | null {
const match = NOTATION_PATTERN.exec(line);
if (!match) {
return null;
}
const variant = match[1] ?? match[2] ?? match[3];
return {
diff: variant === '++' ? 'added' : 'deleted',
markerStart: match.index,
};
}
/**
* Truncate a sequence of HighlightTokens so only the first `maxLen` characters
* (counted across all tokens, recursing into annotations) remain. Used to strip
* trailing diff-notation markers from rendered output.
*/
export function truncateHighlightTokens(
tokens: HighlightToken[],
maxLen: number
): HighlightToken[] {
const out: HighlightToken[] = [];
let remaining = maxLen;
for (const token of tokens) {
if (remaining <= 0) {
break;
}
const len = highlightTokenLength(token);
if (len <= remaining) {
out.push(token);
remaining -= len;
continue;
}
out.push(sliceHighlightToken(token, remaining));
remaining = 0;
}
return out;
}
function highlightTokenLength(token: HighlightToken): number {
switch (token.type) {
case 'plain':
return token.content.length;
case 'shiki':
return token.token.content.length;
case 'annotation':
return token.children.reduce((acc, child) => acc + highlightTokenLength(child), 0);
}
}
/**
* Concatenate the text content of a sequence of HighlightTokens, recursing
* into annotation children. Mirror of {@link highlightTokenLength}.
*/
export function getHighlightTokensText(tokens: HighlightToken[]): string {
return tokens
.map((token) => {
switch (token.type) {
case 'plain':
return token.content;
case 'shiki':
return token.token.content;
case 'annotation':
return getHighlightTokensText(token.children);
}
})
.join('');
}
function sliceHighlightToken(token: HighlightToken, maxLen: number): HighlightToken {
switch (token.type) {
case 'plain':
return { type: 'plain', content: token.content.slice(0, maxLen) };
case 'shiki': {
const inner = token.token as ThemedToken & { start?: number; end?: number };
const newContent = inner.content.slice(0, maxLen);
const newToken: ThemedToken & { start?: number; end?: number } = {
...inner,
content: newContent,
};
if (typeof inner.start === 'number') {
newToken.end = inner.start + newContent.length;
}
return { type: 'shiki', token: newToken };
}
case 'annotation':
return { ...token, children: truncateHighlightTokens(token.children, maxLen) };
}
}
export type HighlightToken =
| { type: 'plain'; content: string }
| { type: 'shiki'; token: ThemedToken }
@@ -136,6 +239,9 @@ export async function highlight(
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
const lineText = tokens.map((token) => token.content).join('');
const notation = parseDiffNotation(lineText);
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
@@ -152,9 +258,14 @@ export async function highlight(
currentIndex += 1; // for the \n
const finalTokens = notation
? truncateHighlightTokens(result, notation.markerStart)
: result;
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
diff: notation?.diff ?? null,
tokens: finalTokens,
};
}),
};
@@ -3,7 +3,14 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
import { getNodeText } from '@/lib/document';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
import {
type HighlightTheme,
type HighlightToken,
type RenderedInline,
getHighlightTokensText,
parseDiffNotation,
truncateHighlightTokens,
} from './highlight';
/**
* Parse a code block without highlighting it.
@@ -62,9 +69,14 @@ export function plainHighlight(
};
});
// Detect diff notation against the built tokens (not the raw nodes)
// so any evaluated inline expressions are included in the offset math.
const notation = parseDiffNotation(getHighlightTokensText(tokens));
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
diff: notation?.diff ?? null,
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
};
}),
};
@@ -65,3 +65,40 @@ export function convertCodeStringToBlock(args: {
nodes: lines,
};
}
/**
* Resolve the code text of a code block from its wrapper id.
* Returns null when the block isn't in the DOM (e.g. not yet hydrated).
*/
export function getCodeTextFromId(codeId: string): string | null {
const element = document.getElementById(codeId)?.querySelector('code');
return element ? getCodeText(element) : null;
}
/**
* Compute the code text from the DOM,
* ignoring the empty white space we use for empty lines (represented with a class "ew").
*/
export function getCodeText(code: HTMLElement): string {
let text = '';
const iterate = (node: Node) => {
if (node instanceof HTMLBRElement) {
text += '\n';
} else if (node instanceof HTMLSpanElement) {
if (node.classList.contains('ew')) {
text += '\n';
} else {
text += node.innerText;
}
} else if (node instanceof HTMLElement) {
node.childNodes.forEach(iterate);
} else {
text += node.textContent;
}
};
iterate(code);
return text;
}
@@ -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')} />;
}
@@ -36,6 +36,11 @@ export interface DocumentContext {
* @default false
*/
withLinkPreviews?: boolean;
/**
* Optional table row search query.
*/
tableSearchQuery?: string;
}
export interface DocumentContextProps {
@@ -93,7 +98,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,
]}
@@ -18,7 +18,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
return null;
}
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script' });
ReactDOM.preload('https://iframely.net/embed.js', { as: 'script' });
const embed = await getDataOrNull(
context.contentContext.dataFetcher.getEmbedByUrl({
@@ -41,7 +41,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
}}
data-visual-test="blackout"
/>
<Script strategy="lazyOnload" src="https://cdn.iframe.ly/embed.js" />
<Script strategy="lazyOnload" src="https://iframely.net/embed.js" />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock

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