mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-20 17:43:24 +00:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb77424e7 | |||
| ff26b63013 | |||
| 3ce7e529f7 | |||
| 0f162a45a7 | |||
| 04ca610ef4 | |||
| 02ba0ef601 | |||
| bdc6cd9aba | |||
| b3875a19d7 | |||
| 884d900e38 | |||
| f68755695e | |||
| 9ffde72ced | |||
| 9e3629b12b | |||
| 48f81c80f0 | |||
| 0baa8e5022 | |||
| 59b9663ce3 | |||
| 9be66e3e8a | |||
| 9a0f35c26d | |||
| f1826807a7 | |||
| 887c3f8eac | |||
| d666212674 | |||
| 8cb9131d83 | |||
| 98b36616cb | |||
| eceec6f641 | |||
| 9f4d93502e | |||
| 8ab419a47e | |||
| 56ce5dfd74 | |||
| 370b731972 | |||
| b7fb9e57ca | |||
| 5b9f5fa991 | |||
| bf1f11794e | |||
| 8621c7f571 | |||
| 4a6f91a19d | |||
| 38a1c85fee | |||
| cb71511cda | |||
| afa476c51f | |||
| 1e9ed753a1 | |||
| 22da7961d1 | |||
| e074283bbf | |||
| afa868e0e1 | |||
| c5bdde4401 | |||
| 5efe13e622 | |||
| 41ef3435bc | |||
| 157d5fb9e7 | |||
| ec28a7b686 | |||
| b3e9ff9839 | |||
| aa2292036c | |||
| f7c4538b16 | |||
| b9b444c24e | |||
| 6f1b795d80 | |||
| 0803db07ef | |||
| a9adbc6370 | |||
| f49b0af3fe | |||
| 3d8e89cbe9 | |||
| f0a54bcb27 | |||
| c348583a18 | |||
| a4b238dc0c | |||
| 529afa3fac | |||
| 577bf26d1e | |||
| 4808f36718 | |||
| a100c5a6c8 | |||
| 4962662fb1 | |||
| f7c251e2d2 | |||
| 40524d4d4a | |||
| 1a7ef78fe2 | |||
| 5f3f4da2d2 | |||
| 93eea0b6d9 | |||
| 9dfa9c2db0 | |||
| d9ff25d28e | |||
| ae30a576e8 | |||
| 2e495cb28a | |||
| f517035e38 | |||
| 83bd0d74cd | |||
| 3cfefa40fd | |||
| 59ffb95417 | |||
| 2ebb18dc5a | |||
| 1deb897de1 | |||
| 11d9b80e77 | |||
| e73d9afd86 | |||
| 729501c977 | |||
| 414866cbec | |||
| 9d5e397574 | |||
| 92c9b8b056 | |||
| a73caf69ff | |||
| f72f250b16 | |||
| bd330ac133 | |||
| e31a8f4275 | |||
| cc38ec2098 | |||
| eab11e2d6f | |||
| 152490cd67 | |||
| 833b07b284 | |||
| 5f668605c2 | |||
| 9c762065f2 | |||
| 4af7df8902 | |||
| a3f854e1a8 | |||
| 4c54820055 | |||
| 22f574da9c | |||
| 426aabe39a | |||
| 64de8d2cf6 | |||
| b4b471fab6 | |||
| 17b32994f6 | |||
| bcc0ef8666 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
OpenAPI styling tweaks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Prevent form submission during IME composition
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Support code themes customization
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add sidesheet component, use it for TOC and AIChat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Set <html lang> based on translated language
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support social links
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix signed JWT token usage
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix OpenAPI example not showing
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Variant dropdown not showing in GitBook embed
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Align styling of PageLinkItem and PageDocumentItem
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
---
|
||||
|
||||
Update scalar dependencies.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support `defaultExpanded` in Expandable block
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Better conditional display of page actions
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Improve OpenAPI server URL validation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Header & TOC tweaks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix ordered list item index calculation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix Scalar Try it button justification
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix site redirects for malformed encoded path segments
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Standardise toggle chevrons across the app
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Grow InlineActionButton to take available width on line
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/icons": patch
|
||||
---
|
||||
|
||||
Simplify installation by using public fontawesome mirror.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/browser-types": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add support for custom cookie banner registration
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Better handle huge section groups
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix Embed options in React
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Add OpenAPI 3.1 nullable array support
|
||||
@@ -26,7 +26,7 @@ inputs:
|
||||
description: 'Commit message to associate with the deployment'
|
||||
required: true
|
||||
outputs:
|
||||
deployment-url:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
runs:
|
||||
@@ -52,6 +52,8 @@ runs:
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
@@ -107,7 +109,7 @@ runs:
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||
|
||||
|
||||
- name: Extract middleware version worker ID
|
||||
shell: bash
|
||||
id: extract_middleware_version_id
|
||||
|
||||
@@ -20,7 +20,7 @@ inputs:
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
outputs:
|
||||
deployment-url:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||
runs:
|
||||
@@ -51,6 +51,8 @@ runs:
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_OAUTH_SERVER_URL: ${{ inputs.opItem }}/GITBOOK_OAUTH_SERVER_URL
|
||||
GITBOOK_PREVIEW_BASE_URL: ${{ inputs.opItem }}/GITBOOK_PREVIEW_BASE_URL
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
|
||||
@@ -136,8 +136,9 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-vercel'
|
||||
ARGOS_BUILD_NAME: v2-vercel
|
||||
visual-testing-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2 (Cloudflare)
|
||||
@@ -160,8 +161,9 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
||||
ARGOS_BUILD_NAME: v2-cloudflare
|
||||
visual-testing-customers-v2:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2
|
||||
@@ -184,8 +186,9 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
ARGOS_BUILD_NAME: customers-v2-vercel
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
@@ -208,8 +211,9 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
ARGOS_BUILD_NAME: customers-v2-cloudflare
|
||||
browserless-testing-v2-vercel:
|
||||
runs-on: ubuntu-latest
|
||||
name: Browserless Testing v2 (Vercel)
|
||||
@@ -231,6 +235,7 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
# browserless-testing-v2-cloudflare:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Browserless Testing v2 (Cloudflare)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
- After making code changes, run `bun format`.
|
||||
+4
-4
@@ -6,9 +6,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"turbo": "^2.7.6",
|
||||
"vercel": "^39.4.2"
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.8.16",
|
||||
"vercel": "^50.31.1"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
"overrides": {
|
||||
@@ -41,7 +41,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.159.0",
|
||||
"@gitbook/api": "0.167.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# @gitbook/browser-types
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
|
||||
- 2e495cb: Add Global Privacy Control (GPC) support
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7a11861: Add support for custom cookie banner registration
|
||||
- Updated dependencies [6f1db32]
|
||||
- @gitbook/icons@0.4.3
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
|
||||
@@ -85,10 +85,17 @@ export type GitBookGlobal = {
|
||||
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
|
||||
|
||||
/**
|
||||
* Whether the user has granted cookie consent.
|
||||
* Returns true if cookies are granted, false if rejected or unknown.
|
||||
* Indicates whether cookies tracking is disabled for the current user.
|
||||
* Returns `true` when tracking is disabled, `false` when tracking is enabled,
|
||||
* and `undefined` when the tracking preference is unknown or not yet determined.
|
||||
*/
|
||||
hasApprovedCookies: () => boolean;
|
||||
isCookiesTrackingDisabled: () => boolean | undefined;
|
||||
|
||||
/**
|
||||
* Indicates whether global privacy control is enabled for the current user.
|
||||
* Returns `true` when global privacy control is enabled, `false` when it is disabled.
|
||||
*/
|
||||
isGlobalPrivacyControlEnabled: () => boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b3875a1: Support `assistantName` property to override Assistant name
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 370b731: Docs Embed: Make trademark optional
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b4b471f: Fix unexpected error thrown when using the embed in standalone mode
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 96e24a1: Fix signed JWT token usage
|
||||
- 0aa34cc: Fix Embed options in React
|
||||
- Updated dependencies [6f1db32]
|
||||
- @gitbook/icons@0.4.3
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -49,8 +49,10 @@ GitBook('configure', {
|
||||
}
|
||||
],
|
||||
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||
assistantName: 'Support Assistant',
|
||||
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||
tools: [/* ... */]
|
||||
tools: [/* ... */],
|
||||
closeButton: true,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -99,8 +101,10 @@ frame.configure({
|
||||
}
|
||||
],
|
||||
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||
assistantName: 'Support Assistant',
|
||||
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||
tools: [/* ... */]
|
||||
tools: [/* ... */],
|
||||
closeButton: true
|
||||
});
|
||||
|
||||
// Listen to events
|
||||
@@ -124,6 +128,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||
}}
|
||||
tabs={['assistant', 'docs']}
|
||||
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||
assistantName="Support Assistant"
|
||||
suggestions={['What is GitBook?', 'How do I get started?']}
|
||||
actions={[
|
||||
{
|
||||
@@ -133,6 +138,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||
}
|
||||
]}
|
||||
tools={[/* ... */]}
|
||||
closeButton
|
||||
/>
|
||||
</GitBookProvider>
|
||||
```
|
||||
@@ -223,15 +229,62 @@ Available in: Standalone script, NPM package, React components
|
||||
Override which tabs are displayed. Defaults to your site's configuration.
|
||||
|
||||
- **Type**: `('assistant' | 'docs')[]`
|
||||
- **Options**:
|
||||
- `['assistant', 'docs']` - Show both tabs
|
||||
- `['assistant']` - Show only the assistant tab
|
||||
- `['docs']` - Show only the docs tab
|
||||
|
||||
```javascript
|
||||
tabs: ['assistant', 'docs']
|
||||
```
|
||||
|
||||
### `closeButton`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Display a close (`x`) button in the embed sidebar.
|
||||
|
||||
- **Type**: `boolean`
|
||||
- **Default**: `false`
|
||||
|
||||
```javascript
|
||||
closeButton: true
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- When clicked, the embed sends a `close` event to the parent page.
|
||||
- In the standalone script, this event is handled automatically and the widget closes.
|
||||
- In custom iframe integrations (NPM package), you must listen for the `close` event and decide how to hide/collapse your UI.
|
||||
- In React, this works automatically when using the standard widget flow. If you build custom frame wiring, handle `close` the same way as the NPM package.
|
||||
|
||||
NPM package example:
|
||||
|
||||
```typescript
|
||||
const frame = gitbook.createFrame(iframe);
|
||||
|
||||
frame.configure({
|
||||
closeButton: true
|
||||
});
|
||||
|
||||
const unsubscribe = frame.on('close', () => {
|
||||
// Hide your modal/drawer/container
|
||||
container.classList.add('hidden');
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The close button is rendered in the same sidebar area as tabs/actions.
|
||||
- If your UI hides that sidebar or doesn't render it, the button will not be visible.
|
||||
|
||||
### `trademark`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Display GitBook branding in the embed. Defaults to true.
|
||||
|
||||
- **Type**: `boolean`
|
||||
- **Default**: `true`
|
||||
|
||||
```javascript
|
||||
trademark: true
|
||||
```
|
||||
|
||||
### `actions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
@@ -278,6 +331,18 @@ greeting: {
|
||||
}
|
||||
```
|
||||
|
||||
### `assistantName`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Override the assistant name displayed in the chat header and assistant entry points (for example, sidebar tabs and action labels). This name will be limited to 32 characters to prevent text overflow.
|
||||
|
||||
- **Type**: `string`
|
||||
|
||||
```javascript
|
||||
assistantName: 'Support Assistant'
|
||||
```
|
||||
|
||||
### `suggestions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -69,6 +69,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -62,11 +62,27 @@ export type GitBookEmbeddableConfiguration = {
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Override the assistant name displayed in the UI.
|
||||
* Limited to 32 characters.
|
||||
*/
|
||||
assistantName?: string;
|
||||
|
||||
/** Suggestions of questions to be displayed in the welcome page. */
|
||||
suggestions: string[];
|
||||
|
||||
/** Tools to be provided to the assistant. */
|
||||
tools: GitBookToolDefinition[];
|
||||
|
||||
/**
|
||||
* Display GitBook branding in the embed.
|
||||
*/
|
||||
trademark?: boolean;
|
||||
|
||||
/**
|
||||
* Display a close button inside the assistant.
|
||||
*/
|
||||
closeButton?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,9 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
suggestions = [],
|
||||
tools = [],
|
||||
tabs = ['assistant', 'docs'],
|
||||
trademark = true,
|
||||
closeButton = false,
|
||||
assistantName,
|
||||
} = props;
|
||||
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -46,8 +49,21 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
});
|
||||
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs]);
|
||||
}, [
|
||||
gitbookFrame,
|
||||
actions,
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
tabs,
|
||||
closeButton,
|
||||
trademark,
|
||||
assistantName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
|
||||
@@ -65,6 +65,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
@@ -106,6 +107,10 @@ function getIframe() {
|
||||
widgetWindow.appendChild(widgetIframe);
|
||||
|
||||
_frame = client.createFrame(widgetIframe);
|
||||
_frame.on('close', () => {
|
||||
widgetWindow.classList.add('hidden');
|
||||
widgetButton.classList.remove('open');
|
||||
});
|
||||
}
|
||||
return { iframe: widgetIframe, frame: _frame };
|
||||
}
|
||||
@@ -152,6 +157,19 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
break;
|
||||
case 'configure': {
|
||||
const settings = args[1];
|
||||
|
||||
// If trademark is disabled, change the (branded) icon to the sparkle icon
|
||||
if (
|
||||
settings.trademark === false &&
|
||||
!settings.button?.icon &&
|
||||
frameConfiguration.button.icon === 'assistant'
|
||||
) {
|
||||
settings.button = {
|
||||
label: frameConfiguration.button.label,
|
||||
icon: 'sparkle',
|
||||
};
|
||||
}
|
||||
|
||||
frameConfiguration = {
|
||||
...frameConfiguration,
|
||||
...settings,
|
||||
@@ -193,5 +211,3 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||
// @ts-expect-error - GitBook is not defined in the global scope
|
||||
window.GitBook = GitBook;
|
||||
precalls.forEach((call) => GitBook(...call));
|
||||
|
||||
GitBook('configure', {});
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-loose": "^8.5.2",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
# gitbook
|
||||
|
||||
## 0.22.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f182680: Add backward compatibility for OpenAPI spec content URL
|
||||
- bdc6cd9: Use OpenAPI publicURL as Scalar specUrl
|
||||
- b3875a1: Refactor embeddable context to merge local & site properties in one unified way
|
||||
- eceec6f: Move ~scalar/proxy route to site route group
|
||||
- 887c3f8: Add token verification on OpenAPI proxy
|
||||
- d666212: Add support for localized site section titles
|
||||
- Updated dependencies [b3875a1]
|
||||
- Updated dependencies [bdc6cd9]
|
||||
- Updated dependencies [8ab419a]
|
||||
- Updated dependencies [eceec6f]
|
||||
- Updated dependencies [887c3f8]
|
||||
- @gitbook/embed@0.4.0
|
||||
- @gitbook/react-openapi@1.5.10
|
||||
- @gitbook/openapi-parser@3.0.10
|
||||
|
||||
## 0.22.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e074283: Add server-side proxy for Scalar API client to bypass CORS
|
||||
- afa868e: Show "Ask Assistant" search result immediately
|
||||
- 0803db0: Track opening of search results for records and in the assistant.
|
||||
- cb71511: Wrap tracking call with waitUntil
|
||||
- 4a6f91a: Show social accounts in header
|
||||
- 370b731: Docs Embed: Make trademark optional
|
||||
- afa476c: Add server-side insight event tracking for MCP route
|
||||
- Updated dependencies [e074283]
|
||||
- Updated dependencies [370b731]
|
||||
- @gitbook/openapi-parser@3.0.9
|
||||
- @gitbook/react-openapi@1.5.9
|
||||
- @gitbook/embed@0.3.0
|
||||
|
||||
## 0.22.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9d5e397: Remove code syntax highlighting patch for Safari
|
||||
- 3d8e89c: Show favicon for record search results in the assistant.
|
||||
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
|
||||
- 426aabe: Fix TOC header logo on bold themes
|
||||
- ae30a57: Integrate Ask AI with upcoming insights by passing visitor session IDs.
|
||||
- f7c251e: Update Reddit social account rendering
|
||||
- e31a8f4: Hide cookie banner for AI user agents
|
||||
- 2e495cb: Add Global Privacy Control (GPC) support
|
||||
- Updated dependencies [1a7ef78]
|
||||
- Updated dependencies [93eea0b]
|
||||
- Updated dependencies [b4b471f]
|
||||
- Updated dependencies [5f66860]
|
||||
- Updated dependencies [2ebb18d]
|
||||
- Updated dependencies [11d9b80]
|
||||
- Updated dependencies [2e495cb]
|
||||
- Updated dependencies [f517035]
|
||||
- @gitbook/react-openapi@1.5.8
|
||||
- @gitbook/embed@0.2.2
|
||||
- @gitbook/browser-types@0.1.5
|
||||
|
||||
## 0.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e00d1c5: Support code themes customization
|
||||
- f294818: Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9d6457f: OpenAPI styling tweaks
|
||||
- d738e11: Prevent form submission during IME composition
|
||||
- 1e53376: Add sidesheet component, use it for TOC and AIChat
|
||||
- f7f6f9d: Set <html lang> based on translated language
|
||||
- 89de966: Support social links
|
||||
- b53cb1d: Align styling of PageLinkItem and PageDocumentItem
|
||||
- 27a34b7: Support `defaultExpanded` in Expandable block
|
||||
- a5ef44a: Better conditional display of page actions
|
||||
- 07c02eb: Header & TOC tweaks
|
||||
- 29e2b22: Fix ordered list item index calculation
|
||||
- f6cf62a: Standardise toggle chevrons across the app
|
||||
- b57561d: Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
|
||||
- e172b25: Grow InlineActionButton to take available width on line
|
||||
- 7a11861: Add support for custom cookie banner registration
|
||||
- 8f32f4f: Better handle huge section groups
|
||||
- 0aa34cc: Fix Embed options in React
|
||||
- Updated dependencies [e00d1c5]
|
||||
- Updated dependencies [96e24a1]
|
||||
- Updated dependencies [4e2d863]
|
||||
- Updated dependencies [d3f6c38]
|
||||
- Updated dependencies [3ba9e46]
|
||||
- Updated dependencies [717ad0b]
|
||||
- Updated dependencies [6f1db32]
|
||||
- Updated dependencies [7a11861]
|
||||
- Updated dependencies [0aa34cc]
|
||||
- Updated dependencies [48ca21e]
|
||||
- @gitbook/react-openapi@1.5.7
|
||||
- @gitbook/embed@0.2.1
|
||||
- @gitbook/openapi-parser@3.0.8
|
||||
- @gitbook/icons@0.4.3
|
||||
- @gitbook/browser-types@0.1.4
|
||||
- @gitbook/react-contentkit@0.7.12
|
||||
|
||||
## 0.21.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -21,6 +21,30 @@ const testCases: TestsCase[] = [
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'should not show built-in banner when UserAgent is AI',
|
||||
url: getCustomizationURL({
|
||||
privacyPolicy: {
|
||||
url: 'https://policies.gitbook.com/privacy/cookies',
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
// Override navigator.userAgent to simulate AI crawler (affects isAIUserAgent())
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
get: () =>
|
||||
'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
// Check that built-in banner is not visible for AI UserAgent
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'should not show built-in banner when custom banner is registered',
|
||||
url: getCustomizationURL({
|
||||
|
||||
@@ -232,7 +232,6 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'run-ai-docs.nvidia.com',
|
||||
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
|
||||
@@ -14,11 +14,7 @@ import type { GitBookStandalone } from '@gitbook/embed';
|
||||
import { expect } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
VISITOR_TOKEN_COOKIE,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
} from '@/lib/visitors';
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
@@ -34,6 +30,7 @@ import {
|
||||
getCustomizationURL,
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
@@ -74,6 +71,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
@@ -99,6 +97,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+K');
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
},
|
||||
@@ -111,6 +110,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
},
|
||||
},
|
||||
@@ -122,6 +122,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
@@ -135,6 +136,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
@@ -147,7 +149,9 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
@@ -158,7 +162,7 @@ const searchTestCases: Test[] = [
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
await expect(recommendedQuestions.length).toBeGreaterThanOrEqual(1); // Expect at least 1 question
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill(AI_PROMPT);
|
||||
@@ -182,7 +186,9 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
@@ -199,6 +205,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
@@ -213,7 +220,9 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toBeEmpty();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
@@ -229,7 +238,9 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
@@ -258,6 +269,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'No variants dropdown',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
|
||||
0
|
||||
);
|
||||
@@ -507,6 +519,72 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Language Site (Navigation when switching language variant)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Should resolve to the same page in the new language variant when switching language variant (Source English -> Target Dutch)',
|
||||
url: 'ecosystem/connection-provider',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// Click the dutch language variant in the dropdown
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: 'Yjs (NL)',
|
||||
})
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the NL variant
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('nl/ecosysteem/connection-provider')
|
||||
);
|
||||
// Verify we are on the correct page by checking the h1
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Connectieprovider' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Should resolve to the same page in the new language variant when switching language variant (Source Dutch -> Target Finnish)',
|
||||
url: 'nl/ecosysteem/connection-provider',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// Click the finnish language variant in the dropdown
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: 'Yjs (FI)',
|
||||
})
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the FI variant
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('fi/ekosysteemi/connection-provider')
|
||||
);
|
||||
// Verify we are on the correct page by checking the h1
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Yhteysvälittäjä' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GitBook Site (Sections and Section Groups)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
@@ -839,26 +917,31 @@ const testCases: TestsCase[] = [
|
||||
name: 'Lists',
|
||||
url: 'blocks/lists',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Code',
|
||||
url: 'blocks/code',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Cards',
|
||||
url: 'blocks/cards',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Updates',
|
||||
url: 'blocks/updates',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForFunction(() => {
|
||||
const fonts = Array.from(document.fonts.values());
|
||||
const mjxFonts = fonts.filter(
|
||||
@@ -875,21 +958,25 @@ const testCases: TestsCase[] = [
|
||||
name: 'Files',
|
||||
url: 'blocks/files',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Embeds',
|
||||
url: 'blocks/embeds',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Page links',
|
||||
url: 'blocks/page-links',
|
||||
fullPage: true,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Annotations',
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
},
|
||||
@@ -897,8 +984,18 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Stepper',
|
||||
url: 'blocks/stepper',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Columns',
|
||||
url: 'blocks/columns',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Mermaid',
|
||||
url: 'blocks/mermaid',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{ name: 'Columns', url: 'blocks/columns' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -926,7 +1023,10 @@ const testCases: TestsCase[] = [
|
||||
toggeable: false,
|
||||
},
|
||||
})}`,
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page, { darkMode: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With hero cover',
|
||||
@@ -1545,33 +1645,36 @@ const testCases: TestsCase[] = [
|
||||
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to fallback on invalid token pulled from cookie',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
cookies: (() => {
|
||||
const basePath = '/va-site-redirects-fallback/';
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
'invalidKey',
|
||||
{
|
||||
expiresIn: '24h',
|
||||
}
|
||||
);
|
||||
return [
|
||||
{
|
||||
name: getVisitorAuthCookieName(basePath),
|
||||
value: getVisitorAuthCookieValue(basePath, invalidToken),
|
||||
httpOnly: true,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
run: async (page) => {
|
||||
await expect(page).toHaveURL(/https:\/\/www.google.com/);
|
||||
},
|
||||
},
|
||||
// This test does not work on Playwright
|
||||
// Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
|
||||
// @see https://github.com/microsoft/playwright/issues/34889
|
||||
// {
|
||||
// name: 'Redirect to fallback on invalid token pulled from cookie',
|
||||
// url: '',
|
||||
// screenshot: false,
|
||||
// cookies: (() => {
|
||||
// const basePath = '/va-site-redirects-fallback/';
|
||||
// const invalidToken = jwt.sign(
|
||||
// {
|
||||
// name: 'gitbook-open-tests',
|
||||
// },
|
||||
// 'invalidKey',
|
||||
// {
|
||||
// expiresIn: '24h',
|
||||
// }
|
||||
// );
|
||||
// return [
|
||||
// {
|
||||
// name: getVisitorAuthCookieName(basePath),
|
||||
// value: getVisitorAuthCookieValue(basePath, invalidToken),
|
||||
// httpOnly: true,
|
||||
// },
|
||||
// ];
|
||||
// })(),
|
||||
// run: async (page) => {
|
||||
// await expect(page).toHaveURL(/https:\/\/www.google.com/);
|
||||
// },
|
||||
// },
|
||||
{
|
||||
name: 'Show error message when invalid token is passed to url',
|
||||
screenshot: false,
|
||||
@@ -2020,6 +2123,8 @@ const testCases: TestsCase[] = [
|
||||
name: 'Switch between tabs',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await setTimeToMorning(page);
|
||||
await page.reload();
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await iframe.getByTestId('embed-tab-docs').click(); // Switch to docs tab
|
||||
@@ -2215,7 +2320,7 @@ const testCases: TestsCase[] = [
|
||||
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
|
||||
);
|
||||
const toolConfirmation = iframe
|
||||
.getByTestId('ai-chat-tool-confirmation')
|
||||
.getByTestId('ai-chat-tool-confirm-accept')
|
||||
.first();
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - e2e - all pages', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - e2e - single-page', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
|
||||
@@ -155,9 +155,19 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page) {
|
||||
export async function setTimeToMorning(page: Page) {
|
||||
const now = new Date();
|
||||
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
|
||||
|
||||
await page.clock.install({ time: now });
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page, options?: { darkMode?: boolean }) {
|
||||
const selector = options?.darkMode
|
||||
? 'img[alt="Page cover"].dark\\:inline'
|
||||
: 'img[alt="Page cover"]:not(.dark\\:inline)';
|
||||
// Wait for cover images to exist (not the shimmer placeholder)
|
||||
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
|
||||
await expect(page.locator(selector)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
@@ -25,6 +26,7 @@ const nextConfig = {
|
||||
// GitBook envs
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"vars": {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"assets": {
|
||||
"directory": "../../.open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
|
||||
@@ -6,12 +6,13 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
|
||||
const originalTagCache = doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
|
||||
// We can set a long TTL for the regional cache, as we invalidate it on update
|
||||
regionalCacheTtlSec: 24 * 60 * 60, // 1 day
|
||||
// Because we invalidate the Cache API on update, we can safely set this to true
|
||||
regionalCacheDangerouslyPersistMissingTags: true,
|
||||
shardReplication: {
|
||||
numberOfSoftReplicas: 2,
|
||||
numberOfHardReplicas: 1,
|
||||
numberOfHardReplicas: 2,
|
||||
regionalReplication: {
|
||||
defaultRegion: 'enam',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.21.3",
|
||||
"version": "0.22.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "^3.8.5",
|
||||
"@opennextjs/cloudflare": "^1.14.4",
|
||||
@@ -46,9 +47,11 @@
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.12.3",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "15.4.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
@@ -77,12 +80,12 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3",
|
||||
"negotiator": "^1.0.0"
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@argos-ci/playwright": "^6.4.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -105,7 +108,7 @@
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^39.3.0",
|
||||
"vercel": "^50.15.1",
|
||||
"wrangler": "^4.43.0",
|
||||
"rss-parser": "^3.13.0"
|
||||
},
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
ComputedPageResult,
|
||||
ComputedSectionResult,
|
||||
OrderedComputedResult,
|
||||
SearchSiteContentRequest,
|
||||
} from '@/components/Search/search-types';
|
||||
import type { GitBookBaseContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import type {
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const [context, { organization, site, shareKey }] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
getSiteURLDataFromMiddleware(),
|
||||
]);
|
||||
const body = (await request.json()) as SearchSiteContentRequest;
|
||||
const { query, scope } = body;
|
||||
|
||||
if (query.length <= 1) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const [searchResults, { structure }] = await Promise.all([
|
||||
(async () => {
|
||||
const result = await throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: organization,
|
||||
siteId: site,
|
||||
query,
|
||||
scope,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
})(),
|
||||
(async () => {
|
||||
const result = await throwIfDataError(
|
||||
context.dataFetcher.getPublishedContentSite({
|
||||
organizationId: organization,
|
||||
siteId: site,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
})(),
|
||||
]);
|
||||
|
||||
const results = searchResults
|
||||
.map((resultItem) => {
|
||||
if (resultItem.type === 'record') {
|
||||
const result: OrderedComputedResult = {
|
||||
type: 'record',
|
||||
id: resultItem.id,
|
||||
title: resultItem.title,
|
||||
description: resultItem.description,
|
||||
href: resultItem.url,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
const siteSection = found?.siteSection;
|
||||
const siteSectionGroup = found?.siteSectionGroup;
|
||||
|
||||
return resultItem.pages.map((pageItem) =>
|
||||
transformSitePageResult(context, {
|
||||
pageItem,
|
||||
spaceItem: resultItem,
|
||||
siteSpace: found?.siteSpace,
|
||||
space: found?.siteSpace.space,
|
||||
spaceURL: found?.siteSpace.urls.published,
|
||||
siteSection: siteSection ?? undefined,
|
||||
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
|
||||
})
|
||||
);
|
||||
})
|
||||
.flat(2);
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
|
||||
function transformSitePageResult(
|
||||
context: GitBookBaseContext,
|
||||
args: {
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
space?: Space;
|
||||
siteSpace?: SiteSpace;
|
||||
spaceURL?: string;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup;
|
||||
}
|
||||
): OrderedComputedResult[] {
|
||||
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
|
||||
const { linker } = context;
|
||||
|
||||
const page: ComputedPageResult = {
|
||||
type: 'page',
|
||||
id: `${spaceItem.id}/${pageItem.id}`,
|
||||
title: pageItem.title,
|
||||
href: spaceURL
|
||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
|
||||
: linker.toPathInSpace(pageItem.path),
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
breadcrumbs: [
|
||||
siteSectionGroup && {
|
||||
icon: siteSectionGroup?.icon as IconName,
|
||||
label: siteSectionGroup.title,
|
||||
},
|
||||
siteSection && {
|
||||
icon: siteSection?.icon as IconName,
|
||||
label: siteSection.title,
|
||||
},
|
||||
(siteSection?.siteSpaces?.filter(
|
||||
(space) =>
|
||||
siteSection?.siteSpaces?.filter(
|
||||
(s) => s.space.language === space.space.language
|
||||
).length > 1
|
||||
).length ?? 0) > 1 && siteSpace
|
||||
? {
|
||||
label: siteSpace.title,
|
||||
}
|
||||
: undefined,
|
||||
...pageItem.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
})),
|
||||
].filter((item) => item !== undefined),
|
||||
};
|
||||
|
||||
const pageSections =
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => ({
|
||||
type: 'section',
|
||||
id: `${page.id}/${section.id}`,
|
||||
title: section.title,
|
||||
href: spaceURL
|
||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
|
||||
: linker.toPathInSpace(pageItem.path),
|
||||
body: section.body,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
})) ?? [];
|
||||
|
||||
return [page, ...pageSections];
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { handleOpenAPIProxyOptions, handleOpenAPIProxyRequest } from '@/routes/openapi-proxy';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function HEAD(request: NextRequest) {
|
||||
return handleOpenAPIProxyRequest(request);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return handleOpenAPIProxyOptions();
|
||||
}
|
||||
+28
-8
@@ -18,6 +18,14 @@ export async function GET(
|
||||
<html>
|
||||
<head>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style type="text/css">
|
||||
.control {
|
||||
display: none;
|
||||
}
|
||||
.custom-trigger .control {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -105,17 +113,29 @@ export async function GET(
|
||||
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
|
||||
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
|
||||
</body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
<script>
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
});
|
||||
window.GitBook('open');
|
||||
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
tabs: ['assistant', 'docs'],
|
||||
closeButton: useCustomTrigger
|
||||
});
|
||||
|
||||
if (useCustomTrigger) {
|
||||
document.body.classList.add('custom-trigger');
|
||||
window.GitBook('hide');
|
||||
} else {
|
||||
window.GitBook('open');
|
||||
}
|
||||
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
|
||||
+14
-2
@@ -24,10 +24,22 @@ export async function GET(
|
||||
(function () {
|
||||
const w = window;
|
||||
const gb = w.GitBook;
|
||||
|
||||
function getScriptSearchParams() {
|
||||
const script = document.currentScript;
|
||||
if (!script) return new URLSearchParams();
|
||||
|
||||
const url = new URL(script.src);
|
||||
return url.searchParams;
|
||||
}
|
||||
|
||||
const searchParams = getScriptSearchParams()
|
||||
const token = searchParams.get('jwt_token');
|
||||
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
|
||||
const initFrameOptions = token ? { visitor: { token } } : undefined;
|
||||
|
||||
if (typeof gb === "function") {
|
||||
gb('init', initOptions);
|
||||
gb('init', initOptions, initFrameOptions);
|
||||
} else {
|
||||
var d = document;
|
||||
|
||||
@@ -40,7 +52,7 @@ export async function GET(
|
||||
};
|
||||
w.GitBook = g;
|
||||
|
||||
g('init', initOptions);
|
||||
g('init', initOptions, initFrameOptions);
|
||||
|
||||
const load = function () {
|
||||
const style = document.createElement('link');
|
||||
|
||||
+38
-3
@@ -1,7 +1,11 @@
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
@@ -31,18 +35,49 @@ async function handler(
|
||||
})
|
||||
);
|
||||
|
||||
// Track the search event server-side
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request: nextRequest,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((spaceResult) => {
|
||||
content: results.flatMap((result) => {
|
||||
if (result.type === 'record') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${result.title}`,
|
||||
`Link: ${result.url}`,
|
||||
result.description ? `Content: ${result.description}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceResult.id
|
||||
(siteSpace) => siteSpace.space.id === result.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaceResult.pages.map((pageResult) => {
|
||||
return result.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_SECRET,
|
||||
GITBOOK_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
@@ -25,6 +26,7 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
|
||||
@@ -21,16 +21,15 @@ export async function getSpacePDFContext(
|
||||
const apiToken = await getAPITokenFromMiddleware();
|
||||
|
||||
const basePath = getPDFRoutePath(params);
|
||||
const linker = createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
});
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: apiToken,
|
||||
});
|
||||
|
||||
const baseContext: GitBookBaseContext = {
|
||||
linker,
|
||||
linker: createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
}),
|
||||
dataFetcher,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
|
||||
export const ConfirmControlOutputSchema = z.object({
|
||||
result: z.enum(['confirmed', 'cancelled']),
|
||||
});
|
||||
|
||||
export const ConfirmControlDef = createAIControl({
|
||||
name: 'confirm',
|
||||
exposeAsTool: false,
|
||||
description:
|
||||
'Display a confirmation prompt to the user (Confirm / Cancel) to approve or abort a pending action. Use this when an operation is irreversible, sensitive, or should only proceed with explicit user consent. Returns either a `confirmed` or `cancelled` result based on the user’s click.',
|
||||
inputSchema: z.object({
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Icon to be shown in the confirm button (any Fontawesome icon name).'),
|
||||
label: z.string().describe('Label to be shown in the confirm button.'),
|
||||
}),
|
||||
outputSchema: ConfirmControlOutputSchema,
|
||||
render: (props) => {
|
||||
return <ConfirmControl {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
|
||||
const { label, icon, onSubmit } = props;
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-cancel"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'cancelled' });
|
||||
}}
|
||||
variant="secondary"
|
||||
icon="xmark"
|
||||
label="Cancel"
|
||||
/>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-accept"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'confirmed' });
|
||||
}}
|
||||
className="flex-1"
|
||||
variant="primary"
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Input } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
|
||||
const OTHER_OPTION_ID = '$other';
|
||||
|
||||
export const SingleChoiceControlOutputSchema = z.object({
|
||||
id: z.string().describe('The identifier of the option selected by the user.'),
|
||||
input: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The custom text entered by the user when "Other" is enabled and selected.'),
|
||||
});
|
||||
|
||||
export const SingleChoiceControlDef = createAIControl({
|
||||
name: 'single-choice',
|
||||
exposeAsTool: true,
|
||||
description:
|
||||
'Use this control when you need the user to choose exactly one option from a predefined list.',
|
||||
inputSchema: z.object({
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide the question or instruction that tells the user what single choice they need to make.'
|
||||
),
|
||||
options: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.describe('Provide the short label the user sees for this option.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optionally provide supporting details to help the user understand this option.'
|
||||
),
|
||||
})
|
||||
.describe('Define one selectable option the user can pick.')
|
||||
)
|
||||
.describe(
|
||||
'Provide the list of options the user can choose from. The user must select exactly one.'
|
||||
),
|
||||
allowOther: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to let the user select an "Other" option and enter a custom text response.'
|
||||
),
|
||||
}),
|
||||
outputSchema: SingleChoiceControlOutputSchema,
|
||||
render: (props) => {
|
||||
return <SingleChoiceControl {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControlDef>) {
|
||||
const { prompt, options, allowOther, onSubmit } = props;
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [otherInput, setOtherInput] = React.useState('');
|
||||
|
||||
const canSubmit =
|
||||
selectedId !== null &&
|
||||
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
|
||||
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<div className="no-scrollbar flex flex-1 flex-col gap-1 overflow-auto">
|
||||
<p className="mb-1 font-semibold text-sm">{prompt}</p>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedId === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={`ai-chat-tool-single-choice-option-${option.id}`}
|
||||
onClick={() => {
|
||||
setSelectedId(option.id);
|
||||
}}
|
||||
className={tcls(
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original bg-primary-subtle text-tint-strong'
|
||||
: 'border-tint bg-tint-base hover:bg-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'size-2.5 rounded-full transition-colors',
|
||||
isSelected ? 'bg-primary-original' : 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<p className="font-medium text-sm">{option.label}</p>
|
||||
{option.description ? (
|
||||
<p className="mt-0.5 text-sm text-tint-subtle">
|
||||
{option.description}
|
||||
</p>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowOther ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ai-chat-tool-single-choice-option-other"
|
||||
onClick={() => {
|
||||
setSelectedId(OTHER_OPTION_ID);
|
||||
}}
|
||||
className={tcls(
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'border-primary-original bg-primary-subtle text-tint-strong'
|
||||
: 'border-tint bg-tint-base hover:bg-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'size-2.5 rounded-full transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'bg-primary-original'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="font-medium text-sm">Other</p>
|
||||
{allowOther && selectedId === OTHER_OPTION_ID ? (
|
||||
<Input
|
||||
label="Enter your answer"
|
||||
value={otherInput}
|
||||
onValueChange={setOtherInput}
|
||||
data-testid="ai-chat-tool-single-choice-other-input"
|
||||
autoFocus
|
||||
className="w-full"
|
||||
sizing="small"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
data-testid="ai-chat-tool-single-choice-submit"
|
||||
variant="primary"
|
||||
label="Submit answer"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
if (!canSubmit || !selectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedId === OTHER_OPTION_ID) {
|
||||
onSubmit({
|
||||
id: OTHER_OPTION_ID,
|
||||
input: otherInput.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({ id: selectedId });
|
||||
}}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { ComponentPropsWithRef } from 'react';
|
||||
|
||||
export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={tcls(
|
||||
'min-h-0 animate-present-slow circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-subtle p-2',
|
||||
props.className
|
||||
)}
|
||||
style={{ animationDelay: '0.5s', ...props.style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
AIStreamResponseToolCallPending,
|
||||
AIToolCallResult,
|
||||
AIToolDefinition,
|
||||
} from '@gitbook/api';
|
||||
import type { ZodType, z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
type AIUIToolContext = Pick<AIStreamResponseToolCallPending, 'toolCall' | 'toolCallId'>;
|
||||
|
||||
type AIControlDefinition<
|
||||
Name extends string = any,
|
||||
Input extends Record<string, unknown> = any,
|
||||
Output extends Record<string, unknown> = any,
|
||||
> = AIToolDefinition & {
|
||||
createControl: (args: {
|
||||
context: AIUIToolContext;
|
||||
input: Input;
|
||||
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
|
||||
}) => AIControl<Name, Input, Output>;
|
||||
exposeAsTool: boolean;
|
||||
};
|
||||
|
||||
type AIControlProps<Input = Record<string, unknown>, Output = Record<string, unknown>> = Input & {
|
||||
onSubmit: (output: Output) => Promise<void>;
|
||||
};
|
||||
|
||||
type AIControl<Name = string, Input = Record<string, unknown>, Output = Record<string, unknown>> = {
|
||||
props: AIControlProps<Input, Output>;
|
||||
name: Name;
|
||||
context: AIUIToolContext;
|
||||
render: () => React.ReactNode;
|
||||
};
|
||||
|
||||
type AIControlFromDef<T extends AIControlDefinition> = ReturnType<T['createControl']>;
|
||||
|
||||
export type GetAIControlProps<T extends AIControlDefinition> = AIControlFromDef<T>['props'];
|
||||
|
||||
export function createAIControl<
|
||||
Name extends string,
|
||||
InputSchema extends ZodType<Record<string, unknown>>,
|
||||
OutputSchema extends ZodType<Record<string, unknown>>,
|
||||
>(def: {
|
||||
name: Name;
|
||||
description: string;
|
||||
inputSchema: InputSchema;
|
||||
outputSchema: OutputSchema;
|
||||
render: (props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>>) => React.ReactNode;
|
||||
/**
|
||||
* Indicates if the control should be exposed as a tool or not.
|
||||
*/
|
||||
exposeAsTool: boolean;
|
||||
}): AIControlDefinition<Name, z.infer<InputSchema>, z.infer<OutputSchema>> {
|
||||
return {
|
||||
name: `ui--${def.name}`,
|
||||
description: def.description,
|
||||
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
|
||||
createControl: ({ context, input, send }) => {
|
||||
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
|
||||
...input,
|
||||
onSubmit: async (output) => {
|
||||
await send({
|
||||
output,
|
||||
summary: {
|
||||
icon: 'check',
|
||||
text: 'Submitted',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
props,
|
||||
name: def.name,
|
||||
context,
|
||||
render: () => def.render(props),
|
||||
};
|
||||
},
|
||||
exposeAsTool: def.exposeAsTool,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConfirmControlDef } from './ConfirmControl';
|
||||
import { SingleChoiceControlDef } from './SingleChoiceControl';
|
||||
|
||||
const CONTROLS = [SingleChoiceControlDef, ConfirmControlDef];
|
||||
|
||||
export type AnyAIControlTool = (typeof CONTROLS)[number];
|
||||
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
|
||||
|
||||
export function getControlTools(): AnyAIControlTool[] {
|
||||
return CONTROLS.filter((control) => control.exposeAsTool);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HighlightQuery } from '@/components/Search/HighlightQuery';
|
||||
import { Link, StyledLink } from '@/components/primitives';
|
||||
import { Favicon } from '@/components/utils';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import type * as React from 'react';
|
||||
|
||||
/**
|
||||
@@ -138,25 +140,37 @@ async function DescriptionForSearchToolCall(props: {
|
||||
// Resolve all hrefs for search results in parallel
|
||||
const searchResultsWithHrefs = await Promise.all(
|
||||
toolCall.results.map(async (result) => {
|
||||
const resolved = await resolveContentRef(
|
||||
result.anchor
|
||||
? {
|
||||
kind: 'anchor',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
anchor: result.anchor,
|
||||
}
|
||||
: {
|
||||
kind: 'page',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
},
|
||||
context
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
href: resolved?.href || '#',
|
||||
};
|
||||
switch (result.type) {
|
||||
case 'page': {
|
||||
const resolved = await resolveContentRef(
|
||||
result.anchor
|
||||
? {
|
||||
kind: 'anchor',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
anchor: result.anchor,
|
||||
}
|
||||
: {
|
||||
kind: 'page',
|
||||
page: result.pageId,
|
||||
space: result.spaceId,
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
href: resolved?.href || '#',
|
||||
};
|
||||
}
|
||||
case 'record':
|
||||
return {
|
||||
...result,
|
||||
href: result.url ?? '#',
|
||||
};
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -192,45 +206,80 @@ async function DescriptionForSearchToolCall(props: {
|
||||
{hasResults ? (
|
||||
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
key={`${result.pageId}-${index}`}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={result.href}
|
||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||
{searchResultsWithHrefs.map((result, index) => {
|
||||
const resultKey = (() => {
|
||||
switch (result.type) {
|
||||
case 'page':
|
||||
return `${result.spaceId}/${result.pageId}`;
|
||||
case 'record':
|
||||
return result.recordId;
|
||||
default:
|
||||
assertNever(result);
|
||||
}
|
||||
})();
|
||||
|
||||
const iconClassName = 'mt-1 size-3 shrink-0 text-tint-subtle';
|
||||
const icon = <Icon icon="memo" className={iconClassName} />;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${resultKey}-${index}`}
|
||||
className="animate-fade-in-slow"
|
||||
style={{
|
||||
animationDelay: `${index * 25}ms`,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon="memo"
|
||||
className="mt-1 size-3 shrink-0 text-tint-subtle"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-tint">
|
||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.title}
|
||||
/>
|
||||
</h3>
|
||||
{result.description && (
|
||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||
<Link
|
||||
href={result.href}
|
||||
className="flex items-start gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md px-3 py-2 transition-colors hover:bg-primary-hover"
|
||||
insights={
|
||||
result.type === 'record'
|
||||
? {
|
||||
type: 'search_open_result',
|
||||
query: toolCall.query,
|
||||
result: {
|
||||
recordId: result.recordId,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'search_open_result',
|
||||
query: toolCall.query,
|
||||
result: {
|
||||
spaceId: result.spaceId,
|
||||
pageId: result.pageId,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{result.type === 'record' ? (
|
||||
<Favicon url={result.href} className={iconClassName} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<div className="flex flex-col gap-1 text-tint">
|
||||
<h3 className="line-clamp-2 font-medium text-sm text-tint">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.description}
|
||||
text={result.title}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="ml-auto size-3 shrink-0 self-center"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</h3>
|
||||
{result.description && (
|
||||
<p className="line-clamp-2 text-tint-subtle text-xs">
|
||||
<HighlightQuery
|
||||
query={toolCall.query}
|
||||
text={result.description}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="ml-auto size-3 shrink-0 self-center"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
type SiteInsightsSession,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
@@ -22,6 +23,7 @@ export async function* streamAIChatResponse({
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
session,
|
||||
options,
|
||||
}: {
|
||||
message?: string;
|
||||
@@ -29,6 +31,7 @@ export async function* streamAIChatResponse({
|
||||
previousResponseId?: string;
|
||||
toolCall?: AIToolCallResult;
|
||||
tools?: AIToolDefinition[];
|
||||
session: SiteInsightsSession;
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
@@ -57,6 +60,7 @@ export async function* streamAIChatResponse({
|
||||
previousResponseId,
|
||||
toolCall,
|
||||
tools,
|
||||
session,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { type AnyAIControlTool, getControlTools } from './controls';
|
||||
|
||||
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
return [...getControlTools(), ...integrationTools];
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type AIConfig = {
|
||||
aiMode: CustomizationAIMode;
|
||||
suggestions?: string[];
|
||||
trademark: boolean;
|
||||
assistantName?: string;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -54,10 +55,10 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||
const AIContext = React.createContext<AIConfig | null>(null);
|
||||
|
||||
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
||||
const { aiMode, trademark, suggestions, greeting, children } = props;
|
||||
const { aiMode, trademark, suggestions, greeting, assistantName, children } = props;
|
||||
const value = React.useMemo(
|
||||
() => ({ aiMode, trademark, suggestions, greeting }),
|
||||
[aiMode, trademark, suggestions, greeting]
|
||||
() => ({ aiMode, trademark, suggestions, greeting, assistantName }),
|
||||
[aiMode, trademark, suggestions, greeting, assistantName]
|
||||
);
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
@@ -91,7 +92,7 @@ export function useAI(): AIContext {
|
||||
if (config.aiMode === CustomizationAIMode.Assistant) {
|
||||
assistants.push({
|
||||
id: 'gitbook-assistant',
|
||||
label: getAIChatName(language, config.trademark),
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { getVisitor, useTrackEvent } from '../Insights';
|
||||
import { getSession } from '../Insights/sessions';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
|
||||
export type AIChatMessage = {
|
||||
@@ -23,21 +26,6 @@ export type AIChatMessage = {
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type AIChatPendingTool = {
|
||||
icon?: IconName;
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Confirm the tool call by calling this function.
|
||||
*/
|
||||
confirm: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Tool call result to cancel it.
|
||||
*/
|
||||
cancelToolCall: AIToolCallResult;
|
||||
};
|
||||
|
||||
export type AIChatState = {
|
||||
/**
|
||||
* If true, the chat is open.
|
||||
@@ -70,9 +58,9 @@ export type AIChatState = {
|
||||
followUpSuggestions: string[];
|
||||
|
||||
/**
|
||||
* Tools that are pending confirmation to be executed.
|
||||
* Control to be displayed to the user.
|
||||
*/
|
||||
pendingTools: AIChatPendingTool[];
|
||||
control: AnyAIControl | null;
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
@@ -126,7 +114,7 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
control: null,
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
@@ -212,7 +200,7 @@ export function AIChatProvider(props: {
|
||||
return {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
control: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -227,10 +215,10 @@ export function AIChatProvider(props: {
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const toolDef = integrationTools.find((tool) => tool.name === event.toolCall.tool);
|
||||
const tools = getTools();
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef) {
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
@@ -263,15 +251,20 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
const tools = getTools();
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
tools: integrationTools.map((tool) => ({
|
||||
session: {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
},
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// Issue with the schema generated by Zod and Next.js serialization.
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
options: {
|
||||
@@ -315,46 +308,84 @@ export function AIChatProvider(props: {
|
||||
break;
|
||||
}
|
||||
case 'response_tool_call_pending': {
|
||||
const toolDef = integrationTools.find(
|
||||
(tool) => tool.name === event.toolCall.tool
|
||||
);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
if (!toolDef) {
|
||||
throw new Error(`Tool ${event.toolCall.tool} not found`);
|
||||
}
|
||||
|
||||
const confirmation = toolDef.confirmation;
|
||||
if ('createControl' in toolDef) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
control: toolDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: event.toolCall.input as any,
|
||||
send: async (result) => {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: result.output,
|
||||
summary: result.summary,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
|
||||
if (confirmation) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
pendingTools: [
|
||||
...state.pendingTools,
|
||||
{
|
||||
icon: confirmation.icon,
|
||||
label: confirmation.label,
|
||||
cancelToolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: {
|
||||
cancelled: 'User did not confirm the tool call',
|
||||
},
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
confirm: async () => {
|
||||
await executeToolCall(event);
|
||||
},
|
||||
control: ConfirmControlDef.createControl({
|
||||
context: {
|
||||
toolCall: event.toolCall,
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
],
|
||||
input: {
|
||||
label: confirmation.label,
|
||||
icon: confirmation.icon,
|
||||
},
|
||||
send: async (result) => {
|
||||
const output = ConfirmControlOutputSchema.parse(
|
||||
result.output
|
||||
);
|
||||
switch (output.result) {
|
||||
case 'cancelled': {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
output: { cancelled: true },
|
||||
summary: {
|
||||
icon: 'forward',
|
||||
text: tString(
|
||||
language,
|
||||
'tool_call_skipped',
|
||||
confirmation.label
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'confirmed':
|
||||
await executeToolCall(event);
|
||||
break;
|
||||
default:
|
||||
assertNever(output.result);
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
} else {
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
|
||||
toolToExecute = event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -403,7 +434,11 @@ export function AIChatProvider(props: {
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, pendingTools } = globalState.getState();
|
||||
const { query, messages, control } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
@@ -448,15 +483,9 @@ export function AIChatProvider(props: {
|
||||
};
|
||||
});
|
||||
|
||||
const pendingTool = pendingTools[0];
|
||||
streamResponse({
|
||||
message: input.message,
|
||||
// If we had a pending tool call, we need to send it as being cancelled
|
||||
// otherwise the AI will fail to process the message
|
||||
...(pendingTool ? { toolCall: pendingTool.cancelToolCall } : {}),
|
||||
});
|
||||
streamResponse({ message: input.message });
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse]
|
||||
[setSearchState, trackEvent, streamResponse, language]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
@@ -467,7 +496,7 @@ export function AIChatProvider(props: {
|
||||
messages: [],
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
pendingTools: [],
|
||||
control: null,
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useNow } from '../hooks';
|
||||
import { Button } from '../primitives';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { AIChatControl } from './AIChatControl';
|
||||
import { AIChatControlButton } from './AIChatControlButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
@@ -87,11 +88,11 @@ export function AIChat() {
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<EmbeddableFrameHeader>
|
||||
<EmbeddableFrameHeader className="not-embed:px-4">
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, config.trademark)}
|
||||
{config.assistantName ?? getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
@@ -106,11 +107,12 @@ export function AIChat() {
|
||||
/>
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<EmbeddableFrameBody className="not-embed:px-4">
|
||||
<AIChatBody
|
||||
chatController={chatController}
|
||||
chat={chat}
|
||||
suggestions={config.suggestions}
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
@@ -141,7 +143,7 @@ export function AIChatDynamicIcon(props: {
|
||||
? 'working'
|
||||
: 'thinking'
|
||||
: chat.messages.length > 0
|
||||
? chat.pendingTools.length > 0
|
||||
? chat.control
|
||||
? 'confirm'
|
||||
: 'done'
|
||||
: 'default'
|
||||
@@ -199,13 +201,13 @@ export function AIChatBody(props: {
|
||||
chat: AIChatState;
|
||||
welcomeMessage?: string;
|
||||
suggestions?: string[];
|
||||
trademark?: boolean;
|
||||
greeting?: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
}) {
|
||||
const { chatController, chat, suggestions, greeting } = props;
|
||||
const { trademark } = useAI().config;
|
||||
const { chatController, chat, suggestions, greeting, trademark } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
||||
@@ -223,8 +225,8 @@ export function AIChatBody(props: {
|
||||
return (
|
||||
<>
|
||||
<ScrollContainer
|
||||
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="p-4 gutter-stable flex flex-col gap-4"
|
||||
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4"
|
||||
orientation="vertical"
|
||||
trailing={{ fade: false, button: true }}
|
||||
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
@@ -272,17 +274,21 @@ export function AIChatBody(props: {
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<div className="flex min-h-0 flex-col gap-2 pb-4">
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
/>
|
||||
{chat.control ? (
|
||||
<AIChatControl control={chat.control} />
|
||||
) : (
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AIChatButton(props: {
|
||||
}) {
|
||||
const { assistant, showLabel = true, withShortcut = true } = props;
|
||||
const language = useLanguage();
|
||||
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '#header-content');
|
||||
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '[data-gb-header-content]');
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { AnyAIControl } from '../AI/controls';
|
||||
|
||||
export function AIChatControl(props: { control: AnyAIControl }) {
|
||||
const { control } = props;
|
||||
return control.render();
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
@@ -67,7 +66,6 @@ export function AIChatMessages(props: {
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
isLastMessage ? 'basis-full' : '',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'origin-top-left text-tint-strong'
|
||||
@@ -96,14 +94,13 @@ export function AIChatMessages(props: {
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
|
||||
@@ -20,11 +20,12 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-start gap-2 self-start"
|
||||
className="flex max-w-full max-w-full flex-col items-start gap-2 self-start"
|
||||
data-testid="ai-chat-suggested-questions"
|
||||
>
|
||||
{suggestions.map((question, index) => (
|
||||
<Button
|
||||
truncate={false}
|
||||
data-testid="ai-chat-suggested-question"
|
||||
key={question}
|
||||
variant="blank"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import type { AIChatState } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
/**
|
||||
* Display buttons to confirm tool calls.
|
||||
*/
|
||||
export function AIChatToolConfirmations(props: {
|
||||
chat: AIChatState;
|
||||
}) {
|
||||
const { chat } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
useHotkeys(
|
||||
'mod+enter',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chat.pendingTools[0]?.confirm();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[chat.pendingTools]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
|
||||
{chat.pendingTools.map((tool, index) => (
|
||||
<div
|
||||
className="flex w-full animate-present-slow flex-col gap-1"
|
||||
key={index}
|
||||
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||
>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirmation"
|
||||
onClick={() => {
|
||||
tool.confirm();
|
||||
}}
|
||||
tabIndex={index}
|
||||
label={tool.label}
|
||||
className="w-full justify-center"
|
||||
size={index === 0 ? 'medium' : 'small'}
|
||||
variant={index === 0 ? 'primary' : 'secondary'}
|
||||
icon={tool.icon}
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div
|
||||
className="flex pointer-none:hidden w-full animate-fade-in-slow items-center justify-end gap-2 text-tint text-xs"
|
||||
style={{ animationDelay: '1000ms' }}
|
||||
>
|
||||
{t(
|
||||
language,
|
||||
'press_to_confirm',
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'enter']}
|
||||
className="mx-0 text-tint"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,18 +17,19 @@ export function AIChatFollowupSuggestions(props: {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div
|
||||
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
|
||||
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
|
||||
data-testid="ai-chat-followup-suggestions"
|
||||
>
|
||||
{chat.followUpSuggestions.map((suggestion, index) => (
|
||||
<Button
|
||||
truncate={false}
|
||||
data-testid="ai-chat-followup-suggestion"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
chatController.postMessage({ message: suggestion });
|
||||
}}
|
||||
label={suggestion}
|
||||
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
|
||||
className="starting:h-0 max-w-full origin-left animate-blur-in-slow border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 hover:bg-primary-hover"
|
||||
size="small"
|
||||
variant="blank"
|
||||
style={{
|
||||
|
||||
@@ -14,10 +14,6 @@ const adaptiveVisitorReaderCache = new Map<
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
|
||||
>();
|
||||
|
||||
export function clearAdaptiveVisitorClaimsCache() {
|
||||
adaptiveVisitorReaderCache.clear();
|
||||
}
|
||||
|
||||
function createResourceReader<T>(promise: Promise<T>) {
|
||||
let result: T | null | undefined;
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { MotionConfig } from 'motion/react';
|
||||
import { MotionConfig, motion } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useVisitor } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
ToolbarButton,
|
||||
ToolbarButtonGroup,
|
||||
type ToolbarButtonProps,
|
||||
ToolbarSeparator,
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
@@ -22,6 +20,7 @@ import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import { ToolbarDate } from './ToolbarDate';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
|
||||
@@ -41,7 +40,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
onToggleMinify,
|
||||
});
|
||||
|
||||
const visitorSession = useVisitorSession();
|
||||
const visitor = useVisitor();
|
||||
|
||||
const toolbarControls: ToolbarControlsContextValue = {
|
||||
minimize,
|
||||
@@ -81,7 +80,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitorSession?.organizationId === context.organizationId) {
|
||||
if (visitor?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<AuthenticatedUserToolbar
|
||||
@@ -132,7 +131,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
@@ -141,14 +140,13 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||
<ToolbarDate value={changeRequest.updatedAt} />{' '}
|
||||
<motion.span layout="position">by {author}</motion.span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
@@ -207,18 +205,11 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Created <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarActions>
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
@@ -280,22 +271,11 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
minified={minified}
|
||||
onMinifiedChange={onMinifiedChange}
|
||||
label="Only visible to your GitBook organization"
|
||||
>
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Updated <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
@@ -305,18 +285,18 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
title="View site configuration"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gears"
|
||||
icon="folder-gear"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Customize in GitBook"
|
||||
title="Customize site"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/customization/general`,
|
||||
siteId: site.id,
|
||||
@@ -327,7 +307,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
|
||||
{/* Open insights in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open insights in GitBook"
|
||||
title="Open insights"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/insights`,
|
||||
siteId: site.id,
|
||||
@@ -361,13 +341,13 @@ function EditPageButton(props: {
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit in GitBook"
|
||||
title="Edit this page"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pencil"
|
||||
icon="pen-to-square"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,21 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
import styles from './Toolbar.module.css';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
const ARC_DURATION_SECONDS = 0.4;
|
||||
const ARC_STAGGER_MS = 80;
|
||||
const BASE_ROTATION_DEG = 95;
|
||||
const ROTATION_STEP_DEG = 18;
|
||||
// Params for the expanding arc defined separately for easier tweaking.
|
||||
const ARC_PARAMS = {
|
||||
arcWidth: 505,
|
||||
arcHeight: 400,
|
||||
arcRadius: 34,
|
||||
startDistance: -240,
|
||||
spreadDistance: 45,
|
||||
fromDistance: -286,
|
||||
fromSpread: 0,
|
||||
durationSeconds: 0.6,
|
||||
staggerMs: 80,
|
||||
baseRotationDeg: 95,
|
||||
rotationStepDeg: 18,
|
||||
offsetAnchorY: 40,
|
||||
} as const;
|
||||
|
||||
interface HideToolbarButtonProps {
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
@@ -23,30 +34,56 @@ interface HideToolbarButtonProps {
|
||||
export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
const { motionValues } = props;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [closing, setClosing] = React.useState(false);
|
||||
const controls = useToolbarControls();
|
||||
const closingTimeoutRef = useRef(0);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const closeArcMenu = React.useCallback(() => {
|
||||
if (!open || closing) return;
|
||||
setClosing(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
window.clearTimeout(closingTimeoutRef.current);
|
||||
|
||||
// Wait for the exit animation to complete before unmounting
|
||||
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
|
||||
closingTimeoutRef.current = window.setTimeout(() => {
|
||||
setOpen(false);
|
||||
setClosing(false);
|
||||
}, totalDuration);
|
||||
}, [open, closing]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
React.useEffect(() => {
|
||||
return () => window.clearTimeout(closingTimeoutRef.current);
|
||||
}, []);
|
||||
|
||||
const handleClickOutsideArcMenu = (event: Event) => {
|
||||
// Don't close the arc if we are clicking on the button itself
|
||||
if (buttonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
closeArcMenu();
|
||||
};
|
||||
// @ts-expect-error wrong type for ref
|
||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||
|
||||
// Close arc menu on scroll
|
||||
// Close arc menu on scroll or resize
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleScroll = () => setOpen(false);
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
const handleClose = () => closeArcMenu();
|
||||
window.addEventListener('scroll', handleClose, { passive: true });
|
||||
window.addEventListener('resize', handleClose, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [open]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleClose);
|
||||
window.removeEventListener('resize', handleClose);
|
||||
};
|
||||
}, [open, closeArcMenu]);
|
||||
|
||||
const items = [
|
||||
controls?.minimize
|
||||
@@ -84,18 +121,36 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
ref={buttonRef}
|
||||
title={open ? 'Hide options' : 'Hide toolbar'}
|
||||
title={open ? undefined : 'Hide toolbar'}
|
||||
className={
|
||||
open || closing
|
||||
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (open || closing) {
|
||||
closeArcMenu();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
motionValues={motionValues}
|
||||
icon="eye-slash"
|
||||
icon="gear"
|
||||
>
|
||||
{/* Expanding arc menu */}
|
||||
{open && (
|
||||
{(open || closing) && (
|
||||
<motion.div
|
||||
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
|
||||
style={sharedMotionStyle as React.CSSProperties | undefined}
|
||||
style={
|
||||
{
|
||||
...sharedMotionStyle,
|
||||
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
|
||||
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
|
||||
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
|
||||
'--start-distance': `${ARC_PARAMS.startDistance}px`,
|
||||
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -107,11 +162,12 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
{items.map((item, index) => (
|
||||
<ArcToolbarButton
|
||||
index={index}
|
||||
staggerIndex={items.length - 1 - index}
|
||||
key={item.icon}
|
||||
staggerIndex={closing ? index : items.length - 1 - index}
|
||||
key={item.id}
|
||||
closing={closing}
|
||||
{...item}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
closeArcMenu();
|
||||
item.onClick?.();
|
||||
}}
|
||||
/>
|
||||
@@ -127,13 +183,13 @@ type ArcMenuItem = {
|
||||
id: string;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
|
||||
index: number;
|
||||
staggerIndex?: number;
|
||||
closing?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
@@ -143,40 +199,36 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
const {
|
||||
index,
|
||||
staggerIndex = index,
|
||||
closing = false,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
onClick = () => {},
|
||||
onClick,
|
||||
icon,
|
||||
iconClassName,
|
||||
} = props;
|
||||
|
||||
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
|
||||
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
|
||||
|
||||
// Calculate rotation based on position along the arc
|
||||
const calculateRotation = () => {
|
||||
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
|
||||
};
|
||||
|
||||
const itemRotation = calculateRotation();
|
||||
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
onClick={onClick ? () => onClick() : undefined}
|
||||
style={
|
||||
{
|
||||
'--from-offset-distance': fromOffset,
|
||||
'--target-offset-distance': targetOffset,
|
||||
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
|
||||
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
|
||||
'--rotation-offset': `${itemRotation}deg`,
|
||||
offsetPath: 'border-box',
|
||||
offsetDistance: targetOffset,
|
||||
offsetAnchor: '0% 40%',
|
||||
offsetRotate: `auto ${itemRotation}deg`,
|
||||
offsetDistance: fromOffset,
|
||||
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
|
||||
offsetRotate: 'auto 90deg',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={tcls(
|
||||
@@ -185,48 +237,43 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
'top-0',
|
||||
'left-0',
|
||||
'w-40',
|
||||
'opacity-0',
|
||||
'pointer-events-auto',
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
styles.arcMenuItem,
|
||||
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex shrink-0 items-center justify-center gap-1',
|
||||
'h-8 w-8 rounded-full border',
|
||||
'truncate text-sm',
|
||||
'cursor-pointer transition-colors',
|
||||
'group-hover:-rotate-5 group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'text-tint-1 dark:text-tint-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
|
||||
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
|
||||
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
|
||||
)}
|
||||
style={{
|
||||
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
|
||||
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-3.5 shrink-0 group-hover:scale-110',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
|
||||
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
|
||||
'group-hover:rotate-2 group-hover:scale-105',
|
||||
'text-neutral-1 dark:text-neutral-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
.arcMenuItem {
|
||||
animation-name: hide-toolbar-arc-enter;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: var(--arc-duration, 0.4s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
@@ -28,13 +28,53 @@
|
||||
|
||||
@keyframes hide-toolbar-arc-enter {
|
||||
from {
|
||||
offset-distance: var(--start-distance);
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
offset-distance: var(--from-offset-distance);
|
||||
offset-rotate: auto 90deg;
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
offset-rotate: auto var(--rotation-offset);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hide-toolbar-arc-exit {
|
||||
from {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
offset-rotate: auto var(--rotation-offset);
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--from-offset-distance);
|
||||
offset-rotate: auto 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.arcMenuItemExit {
|
||||
animation-name: hide-toolbar-arc-exit;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: both;
|
||||
animation-duration: var(--arc-duration, 0.3s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
offset-path: border-box;
|
||||
offset-anchor: 0% 0%;
|
||||
offset-rotate: auto var(--rotation-offset, 0deg);
|
||||
}
|
||||
|
||||
/* ── Arc label fade ── */
|
||||
@keyframes arc-label-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes arc-label-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.arcLabelFadeIn {
|
||||
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.arcLabelFadeOut {
|
||||
animation: arc-label-fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import React, { isValidElement } from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
@@ -19,15 +20,20 @@ import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
const PILL_STYLE = {
|
||||
'--toolbar-bg': '#1f1d1b',
|
||||
borderRadius: '100px', // Set on `style` so Framer Motion can correct for distortions
|
||||
zIndex: 1, // Ensure pill stacks above the peek label sibling
|
||||
} as React.CSSProperties;
|
||||
|
||||
interface ToolbarProps {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function Toolbar(props: ToolbarProps) {
|
||||
const { children, label, minified, onMinifiedChange } = props;
|
||||
const { children, minified, onMinifiedChange } = props;
|
||||
const controls = useToolbarControls();
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const autoExpandTriggeredRef = React.useRef(false);
|
||||
@@ -35,6 +41,10 @@ export function Toolbar(props: ToolbarProps) {
|
||||
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
||||
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
||||
|
||||
// Track when the pill's layout animation finishes so the hint label
|
||||
// only appears once the toolbar is fully expanded.
|
||||
const [showHint, setShowHint] = React.useState(!minified);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
const handleLoad = () => {
|
||||
@@ -82,6 +92,10 @@ export function Toolbar(props: ToolbarProps) {
|
||||
// Any manual expansion should stop the logo animation so the icon stays in its
|
||||
// “settled” state once the toolbar is open.
|
||||
setShouldAnimateLogo(false);
|
||||
} else {
|
||||
// Hide the hint immediately when minimizing — it will reappear
|
||||
// once the next expand animation completes.
|
||||
setShowHint(false);
|
||||
}
|
||||
}, [minified]);
|
||||
|
||||
@@ -91,47 +105,57 @@ export function Toolbar(props: ToolbarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<ToolbarVisibilityHint show={showHint} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
onLayoutAnimationComplete={() => {
|
||||
if (!minified) {
|
||||
setShowHint(true);
|
||||
}
|
||||
}}
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
|
||||
'relative',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[var(--toolbar-bg)]'
|
||||
)}
|
||||
style={PILL_STYLE}
|
||||
>
|
||||
{/* Logo — double-click to minimize */}
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
|
||||
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
|
||||
)}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
onDoubleClick={(e) => {
|
||||
if (minified) return;
|
||||
e.stopPropagation();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
onMinifiedChange(true);
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? children : null}
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,62 +222,51 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
|
||||
} = props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
const anchor = (
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'text-tint-7 hover:text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'bg-[var(--toolbar-bg)]',
|
||||
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
||||
{children ? children : null}
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'flex',
|
||||
'relative',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'size-8',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
|
||||
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
@@ -300,10 +313,6 @@ function ToolbarButtonWrapper(props: {
|
||||
});
|
||||
}
|
||||
|
||||
export function ToolbarSeparator() {
|
||||
return <div className="h-5 w-px bg-tint-1/3" />;
|
||||
}
|
||||
|
||||
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs ">
|
||||
@@ -328,7 +337,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
|
||||
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
@@ -339,7 +348,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
>
|
||||
{props.subtitle}
|
||||
</motion.span>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { formatDateTime, formatDateWeekday, formatRelative } from '../utils/dates';
|
||||
|
||||
type DateFormat = 'relative' | 'weekday' | 'full';
|
||||
|
||||
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
|
||||
|
||||
/**
|
||||
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
|
||||
*/
|
||||
export function ToolbarDate(props: { value: string }) {
|
||||
const { value } = props;
|
||||
const language = useLanguage();
|
||||
const [formatIndex, setFormatIndex] = React.useState(0);
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
|
||||
const format = DATE_FORMATS[formatIndex] as DateFormat;
|
||||
|
||||
const formatted = React.useMemo(() => {
|
||||
const date = new Date(value);
|
||||
switch (format) {
|
||||
case 'relative':
|
||||
return formatRelative(language.locale, Date.now() - date.getTime());
|
||||
case 'weekday':
|
||||
return formatDateWeekday(date, language.locale);
|
||||
case 'full':
|
||||
return formatDateTime(date, language.locale);
|
||||
}
|
||||
}, [format, language.locale, value]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="inline-flex items-center gap-1"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Vertical dots indicating cycleable formats */}
|
||||
<AnimatePresence>
|
||||
{hovered && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'auto', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-[1px]">
|
||||
{DATE_FORMATS.map((fmt, i) => (
|
||||
<motion.span
|
||||
key={fmt}
|
||||
animate={{
|
||||
opacity: i === formatIndex ? 1 : 0.3,
|
||||
scale: i === formatIndex ? 1 : 0.75,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className="block size-[3px] rounded-full bg-current"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.time
|
||||
layout="position"
|
||||
data-visual-test="transparent"
|
||||
suppressHydrationWarning={true}
|
||||
dateTime={value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={formatIndex}
|
||||
initial={{ y: '-100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
>
|
||||
{formatted}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.time>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'motion/react';
|
||||
import React from 'react';
|
||||
import { getVisibilityHintDismissed, setVisibilityHintDismissed } from './utils';
|
||||
|
||||
interface ToolbarVisibilityHintProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
|
||||
const { show } = props;
|
||||
const [dismissed, setDismissed] = React.useState(() =>
|
||||
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
|
||||
);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
y: show ? 0 : 10,
|
||||
opacity: show ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
}}
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
|
||||
>
|
||||
<span className="whitespace-nowrap text-[11px] text-neutral-9">
|
||||
This toolbar appears only when signed in to GitBook.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVisibilityHintDismissed();
|
||||
setDismissed(true);
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
||||
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
||||
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
||||
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
|
||||
|
||||
type SessionHideReason = 'session' | 'persistent';
|
||||
|
||||
@@ -57,6 +58,20 @@ export const setStoredMinified = (value: boolean) => {
|
||||
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the user has dismissed the "only you can see this" hint.
|
||||
*/
|
||||
export const getVisibilityHintDismissed = (): boolean => {
|
||||
return getSessionStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist that the user dismissed the visibility hint for the current session.
|
||||
*/
|
||||
export const setVisibilityHintDismissed = () => {
|
||||
setSessionStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
|
||||
};
|
||||
|
||||
interface UseToolbarVisibilityOptions {
|
||||
onPersistentClose?: () => void;
|
||||
onSessionClose?: () => void;
|
||||
|
||||
@@ -77,7 +77,8 @@ export async function AdCoverRendering({
|
||||
'shadow-md',
|
||||
'max-h-32',
|
||||
'group-hover/ad:max-h-16',
|
||||
'transition-all'
|
||||
'transition-all',
|
||||
'motion-reduce:transition-none'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -93,7 +94,8 @@ export async function AdCoverRendering({
|
||||
'opacity-0',
|
||||
'group-hover/ad:h-16',
|
||||
'group-hover/ad:opacity-10',
|
||||
'transition-all'
|
||||
'transition-all',
|
||||
'motion-reduce:transition-none'
|
||||
)}
|
||||
>
|
||||
{ad.description}
|
||||
|
||||
@@ -30,11 +30,11 @@ export function AnnouncementBanner(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
id="announcement-banner"
|
||||
data-gb-announcement-banner
|
||||
className="theme-bold:bg-header-background pt-4 pb-2"
|
||||
data-nosnippet=""
|
||||
>
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-all duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||
<Tag
|
||||
href={contentRef?.href ?? ''}
|
||||
|
||||
@@ -7,7 +7,12 @@ import { t, tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { useCustomCookieBanner, useIntegrationsLoaded } from '@/components/Integrations';
|
||||
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
|
||||
import { isAIUserAgent } from '@/lib/browser';
|
||||
import {
|
||||
isCookiesTrackingDisabled,
|
||||
isGlobalPrivacyControlEnabled,
|
||||
setCookiesTracking,
|
||||
} from '../Insights';
|
||||
|
||||
/**
|
||||
* Toast to accept or reject the use of cookies.
|
||||
@@ -18,16 +23,24 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
const language = useLanguage();
|
||||
const integrationsLoaded = useIntegrationsLoaded();
|
||||
const { hasCustomCookieBanner } = useCustomCookieBanner();
|
||||
const isAI = isAIUserAgent();
|
||||
const hasGlobalPrivacyControl = isGlobalPrivacyControlEnabled();
|
||||
|
||||
React.useEffect(() => {
|
||||
// If global privacy control is enabled, reject cookies
|
||||
if (hasGlobalPrivacyControl && !isCookiesTrackingDisabled()) {
|
||||
setCookiesTracking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always wait for integrations to load, and if a custom banner is registered, hide the built-in banner
|
||||
if (!integrationsLoaded || hasCustomCookieBanner) {
|
||||
if (!integrationsLoaded || hasCustomCookieBanner || isAI) {
|
||||
setShow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShow(isCookiesTrackingDisabled() === undefined);
|
||||
}, [hasCustomCookieBanner, integrationsLoaded]);
|
||||
}, [hasCustomCookieBanner, integrationsLoaded, isAI, hasGlobalPrivacyControl]);
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
@@ -70,6 +83,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
'lg:chat-open:mr-80',
|
||||
'xl:chat-open:mr-100',
|
||||
'transition-all',
|
||||
'motion-reduce:transition-none',
|
||||
'duration-300',
|
||||
'text-sm'
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import type { HighlightTheme, RenderedInline } from './highlight';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
export type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
mode: BlockProps<DocumentBlockCode>['context']['mode'];
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { MermaidCodeBlock } from './MermaidCodeBlock';
|
||||
import { type RenderedInline, getInlines, highlight } from './highlight';
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,7 @@ export async function CodeBlock(
|
||||
themes: providedThemes,
|
||||
} = props;
|
||||
const inlines = getInlines(block);
|
||||
const isMermaid = block.data.syntax?.toLowerCase() === 'mermaid';
|
||||
|
||||
let hasInlineExpression = false;
|
||||
|
||||
@@ -74,7 +76,7 @@ export async function CodeBlock(
|
||||
? context.contentContext.customization.styling.codeTheme[themeKey]
|
||||
: undefined);
|
||||
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
|
||||
if (!isMermaid && !isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
|
||||
// In v2, we render the code block server-side
|
||||
const theme = await highlight(block, richInlines, {
|
||||
themes: themes,
|
||||
@@ -92,16 +94,22 @@ export async function CodeBlock(
|
||||
}
|
||||
: {};
|
||||
|
||||
const clientProps = {
|
||||
block,
|
||||
style,
|
||||
inlines: richInlines,
|
||||
inlineExprVariables: variables,
|
||||
mode: context.mode,
|
||||
themes,
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<ClientCodeBlock
|
||||
block={block}
|
||||
style={style}
|
||||
inlines={richInlines}
|
||||
inlineExprVariables={variables}
|
||||
mode={context.mode}
|
||||
themes={themes}
|
||||
/>
|
||||
{isMermaid ? (
|
||||
<MermaidCodeBlock {...clientProps} />
|
||||
) : (
|
||||
<ClientCodeBlock {...clientProps} />
|
||||
)}
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Loading } from '@/components/primitives/Loading';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { getPlainCodeBlock } from './highlight';
|
||||
|
||||
/**
|
||||
* Used to render a Mermaid diagram from a CodeBlock.
|
||||
*/
|
||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style } = props;
|
||||
const source = getPlainCodeBlock(block);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const darkMode = resolvedTheme === 'dark';
|
||||
const id = useSafeId();
|
||||
|
||||
useEffect(() => {
|
||||
const container = diagramRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
|
||||
renderMermaidDiagram({
|
||||
container,
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [source, id, darkMode]);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', style)} contentEditable={false}>
|
||||
<div className={isLoading ? 'invisible absolute inset-x-0' : undefined}>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex h-24 items-center justify-center text-tint">
|
||||
<Loading className="h-8 w-8" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMermaidDiagram(args: {
|
||||
container: HTMLElement;
|
||||
source: string;
|
||||
id: string;
|
||||
darkMode: boolean;
|
||||
}) {
|
||||
const { container, source, id, darkMode } = args;
|
||||
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
|
||||
import('mermaid'),
|
||||
import('@mermaid-js/mermaid-zenuml'),
|
||||
]);
|
||||
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
darkMode,
|
||||
theme: darkMode ? 'dark' : undefined,
|
||||
});
|
||||
|
||||
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
|
||||
container.innerHTML = svg;
|
||||
bindFunctions?.(container);
|
||||
}
|
||||
|
||||
function useSafeId() {
|
||||
const rawId = useId();
|
||||
return useMemo(() => rawId.replace(/[^a-zA-Z0-9]/g, ''), [rawId]);
|
||||
}
|
||||
@@ -53,9 +53,6 @@ export type RenderedInline = {
|
||||
body: React.ReactNode;
|
||||
};
|
||||
|
||||
const isSafari =
|
||||
typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
// Merge bundled Shiki themes with our custom themes so both are available to the highlighter
|
||||
const { getSingletonHighlighter } = createSingletonShorthands(
|
||||
createdBundledHighlighter<any, any>({
|
||||
@@ -94,12 +91,8 @@ export async function highlight(
|
||||
): Promise<HighlightTheme> {
|
||||
const langName = getBlockLang(block);
|
||||
|
||||
if (!langName || (isSafari && ['powershell', 'cpp'].includes(langName))) {
|
||||
// Fallback to plain highlighting if
|
||||
// - language is not found
|
||||
// - TEMP : language is PowerShell or C++ and browser is Safari:
|
||||
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isn’t writable
|
||||
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
|
||||
if (!langName) {
|
||||
// Fallback to plain highlighting if language is not found
|
||||
return plainHighlight(block, inlines, options);
|
||||
}
|
||||
|
||||
@@ -297,7 +290,7 @@ function matchTokenAndInlines(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPlainCodeBlock(
|
||||
export function getPlainCodeBlock(
|
||||
code: DocumentBlockCode,
|
||||
inlines?: InlineIndexed[],
|
||||
options?: {
|
||||
|
||||
@@ -15,7 +15,6 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
|
||||
const title = getNodeFragmentByType(block, 'expandable-title');
|
||||
const body = getNodeFragmentByType(block, 'expandable-body');
|
||||
// @ts-expect-error should be removed once we update the API
|
||||
const isDefaultExpanded = block.data.defaultExpanded ?? false;
|
||||
|
||||
const titleParagraph = title?.nodes[0];
|
||||
|
||||
@@ -24,6 +24,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
className={tcls(
|
||||
textStyle.textSize,
|
||||
'heading',
|
||||
'pdf-heading',
|
||||
'flex',
|
||||
'items-baseline',
|
||||
'scroll-mt-(--content-scroll-margin)',
|
||||
@@ -32,6 +33,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
||||
style,
|
||||
textStyle.marginTop
|
||||
)}
|
||||
data-pdf-heading
|
||||
>
|
||||
<HashLinkButton
|
||||
id={id}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DocumentBlockHint } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { validateIconName } from '@gitbook/icons/icons';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -17,6 +18,10 @@ export function Hint({
|
||||
...contextProps
|
||||
}: BlockProps<DocumentBlockHint>) {
|
||||
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
|
||||
|
||||
const customIcon = block.data.icon;
|
||||
const icon = customIcon && validateIconName(customIcon) ? customIcon : hintStyle.icon;
|
||||
|
||||
const firstNode = block.nodes[0]!;
|
||||
const firstLine = getBlockTextStyle(firstNode);
|
||||
const hasHeading = isHeadingBlock(firstNode);
|
||||
@@ -57,10 +62,7 @@ export function Hint({
|
||||
hintStyle.iconColor
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={hintStyle.icon}
|
||||
className={tcls('size-[1.2em]', 'mt-px', firstLine.lineHeight)}
|
||||
/>
|
||||
<Icon icon={icon} className={tcls('size-[1.2em]', 'mt-px', firstLine.lineHeight)} />
|
||||
</div>
|
||||
{hasHeading ? (
|
||||
<Block
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
'use client';
|
||||
import { clearAdaptiveVisitorClaimsCache } from '@/components/Adaptive/AdaptiveVisitorContextProvider';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { useAI, useAIChatController, useAIChatState } from '../AI';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import { Button, type ButtonProps, Input } from '../primitives';
|
||||
|
||||
type ClaimInput = {
|
||||
key: string;
|
||||
value: string | number | boolean | null;
|
||||
};
|
||||
|
||||
export function InlineActionButton(
|
||||
props: {
|
||||
action: 'ask' | 'search' | 'set-claim';
|
||||
query?: string;
|
||||
claims?: ClaimInput[];
|
||||
} & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
|
||||
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
|
||||
) {
|
||||
const { action, query, claims, buttonProps } = props;
|
||||
const { action, query, buttonProps } = props;
|
||||
|
||||
const { assistants } = useAI();
|
||||
const chatController = useAIChatController();
|
||||
@@ -42,33 +32,11 @@ export function InlineActionButton(
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetClaimAction = () => {
|
||||
if (!claims?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedClaims: Record<string, string | number | boolean | null> = {};
|
||||
for (const claim of claims) {
|
||||
mergedClaims[claim.key] = claim.value;
|
||||
}
|
||||
|
||||
document.cookie = `gitbook-visitor-public=${encodeURIComponent(
|
||||
JSON.stringify(mergedClaims)
|
||||
)}; path=/; SameSite=Lax`;
|
||||
|
||||
clearAdaptiveVisitorClaimsCache();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const icon =
|
||||
action === 'ask' && buttonProps.icon === 'gitbook-assistant' && assistants.length > 0
|
||||
? assistants[0]?.icon
|
||||
: buttonProps.icon;
|
||||
|
||||
if (action === 'set-claim') {
|
||||
return <Button {...buttonProps} onClick={handleSetClaimAction} />;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -17,15 +17,11 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
if ('action' in inline.data) {
|
||||
const actionData = inline.data.action;
|
||||
|
||||
if ('action' in inline.data && 'query' in inline.data.action) {
|
||||
return (
|
||||
<InlineActionButton
|
||||
action={actionData.action}
|
||||
query={actionData.query ?? ''}
|
||||
// @ts-expect-error -- Temporary override until `set-claim` is available in @gitbook/api.
|
||||
claims={actionData.claims}
|
||||
action={inline.data.action.action}
|
||||
query={inline.data.action.query ?? ''}
|
||||
buttonProps={buttonProps}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user