Compare commits

...

57 Commits

Author SHA1 Message Date
Claire Chabas c87cdc8af4 Fix text fragment navigation 2026-02-26 13:39:52 +00:00
Claire Chabas aa2292036c Fix inline code word break (#4057) 2026-02-26 13:28:33 +01:00
Claire Chabas f7c4538b16 Prevent hash link scroll to top in Safari (#4055) 2026-02-26 13:27:04 +01:00
Claire Chabas b9b444c24e Fix inline code overflowing in table cells (#4052) 2026-02-25 18:23:00 +01:00
conico974 6f1b795d80 Improve sharded tag cache performance (#4051) 2026-02-25 17:48:09 +01:00
Samy Pessé 0803db07ef Track opening of search results for records and in the assistant (#4047) 2026-02-25 11:40:39 +01:00
Samy Pessé a9adbc6370 Version Packages (#3991)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-25 11:37:48 +01:00
Greg Bergé f49b0af3fe Fix E2E tests (#4044) 2026-02-25 09:57:47 +01:00
Samy Pessé 3d8e89cbe9 Show favicon for record search results in the assistant (#4042) 2026-02-24 18:57:22 +01:00
Greg Bergé f0a54bcb27 Fix PDF export links to preserve published/custom domains (#4043) 2026-02-24 17:02:01 +00:00
Claire Chabas c348583a18 Prevent inline code from breaking on multiple lines (#4041) 2026-02-24 13:48:33 +01:00
Greg Bergé a4b238dc0c Support close button in embed (#4036) 2026-02-24 11:35:03 +01:00
Zeno Kapitein 529afa3fac Open Assistant from page action without default question (#4038) 2026-02-24 10:04:18 +01:00
Peter White 577bf26d1e Fix anchor ID collisions by removing non-content UX IDs (#4035) 2026-02-24 09:53:36 +01:00
Samy Pessé 4808f36718 Update @gitbook/api to 0.163.0 (#4039) 2026-02-23 19:00:02 +01:00
Samy Pessé a100c5a6c8 Prepare search for upcoming records (#4037) 2026-02-23 18:34:01 +01:00
Zeno Kapitein 4962662fb1 Improve search breadcrumbs (#4034) 2026-02-23 13:40:57 +00:00
Zeno Kapitein f7c251e2d2 Improve Reddit social account URL handling (#4033) 2026-02-23 11:49:26 +01:00
Greg Bergé 40524d4d4a Upgrade Argos & Playwright (#4031) 2026-02-23 10:04:59 +01:00
Nolann B. 1a7ef78fe2 Handle non-array required field in OpenAPI schema (#4030) 2026-02-21 13:34:12 +01:00
Greg Bergé 5f3f4da2d2 Stabilize E2E (#4028) 2026-02-20 15:35:50 +01:00
Nolann B. 93eea0b6d9 Strip trailing slash from OpenAPI server URLs (#4029) 2026-02-20 14:40:36 +01:00
Viktor Renkema 9dfa9c2db0 Add support for rendering Tags (#4006) 2026-02-20 10:03:49 +01:00
spastorelli d9ff25d28e Integrate sites OAuth server flow in GBO for protected resources like MCP (#4015) 2026-02-19 15:48:49 +01:00
Samy Pessé ae30a576e8 Integrate Ask AI with upcoming insights by passing visitor session IDs (#4027) 2026-02-19 14:36:23 +01:00
Nolann B. 2e495cb28a Add Global Privacy Control (GPC) support (#4023) 2026-02-18 21:32:56 +01:00
Nolann B. f517035e38 Remove available scopes list from OAuth2 endpoints (#4022) 2026-02-18 21:20:58 +01:00
spastorelli 83bd0d74cd Fix issue where customizations stored in cookie is shared between different site previews (#4025) 2026-02-18 19:10:54 +01:00
Greg Bergé 3cfefa40fd Support rendering Mermaid blocks (#4018) 2026-02-18 17:00:34 +01:00
Claire Chabas 59ffb95417 Support hint block custom icon (#4007) 2026-02-18 15:02:33 +01:00
Nolann B. 2ebb18dc5a Make OpenAPI Response structure clearer (#4021) 2026-02-18 14:32:07 +01:00
Claire Chabas 1deb897de1 Bump GitBook API (#4019) 2026-02-18 12:53:29 +01:00
Nolann B. 11d9b80e77 Use precedence for OpenAPI servers (#4020) 2026-02-18 01:20:39 +01:00
John Cashman e73d9afd86 fix(pdf): keep headings together across page breaks (#4016) 2026-02-17 15:59:42 +01:00
Greg Bergé 729501c977 Fix language dropdown contrast (#4017) 2026-02-17 14:27:09 +01:00
Greg Bergé 414866cbec Fix external links logic for proxy (#4012) 2026-02-17 11:41:30 +01:00
Claire Chabas 9d5e397574 Remove code syntax highlighting patch for Safari (#4014) 2026-02-16 19:09:19 +01:00
Nolann B. 92c9b8b056 Cache isAIUserAgent result (#4011) 2026-02-16 13:11:43 +00:00
Greg Bergé a73caf69ff Allow to pass a token to the embed script (#4005) 2026-02-16 12:48:02 +01:00
conico974 f72f250b16 Add deploymentId to nextConfig for custom deployment method (#4010) 2026-02-16 12:34:35 +01:00
Viktor Renkema bd330ac133 Remove tracking-wider in search breadcrumbs (#4009) 2026-02-16 11:34:14 +00:00
Nolann B. e31a8f4275 Hide cookie banner for AI user agents (#3999) 2026-02-16 11:49:03 +01:00
Greg Bergé cc38ec2098 Fix regression showing page actions on page without title + description (#4004) 2026-02-13 10:53:25 +01:00
Nolann B. eab11e2d6f Increase timeout for recursive parse test (#4003) 2026-02-13 09:10:25 +00:00
Zeno Kapitein 152490cd67 Normalize undefined site-space language to English in variant categorization (fix language selector) (#4002) 2026-02-13 09:43:45 +01:00
conico974 833b07b284 Bump Vercel version to 50.15.1 (#4001) 2026-02-13 09:43:27 +01:00
Nolann B. 5f668605c2 Use isCookiesTrackingDisabled for cookie consent integrations (#3997)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 20:40:18 +01:00
conico974 9c762065f2 Bump Vercel CLI version (#4000) 2026-02-12 18:52:40 +01:00
Viktor Renkema 4af7df8902 Updates to style and interactions of toolbar (#3998) 2026-02-12 16:08:05 +01:00
Zeno Kapitein a3f854e1a8 Better localise French ask label (#3996) 2026-02-12 15:02:43 +01:00
Zeno Kapitein 4c54820055 Improve Docs Embed layout (#3994) 2026-02-12 14:06:37 +01:00
Zeno Kapitein 22f574da9c Fix followup & suggested questions overflow (#3995) 2026-02-12 14:06:28 +01:00
Zeno Kapitein 426aabe39a Fix sidebar on bold themes (#3993) 2026-02-12 11:48:43 +01:00
Taran Vohra 64de8d2cf6 Add e2e test for language variant navigation (#3992) 2026-02-12 15:07:27 +05:30
Greg Bergé b4b471fab6 Fix unexpected error thrown when using the embed in standalone mode (#3985) 2026-02-11 15:15:00 +01:00
Samy Pessé 17b32994f6 Version Packages (#3990)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-11 14:37:35 +01:00
Greg Bergé bcc0ef8666 Fix links handling in embedded Assistant (#3987) 2026-02-11 13:57:48 +01:00
180 changed files with 4001 additions and 1035 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
OpenAPI styling tweaks
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Prevent form submission during IME composition
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook": minor
"@gitbook/react-openapi": patch
---
Support code themes customization
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Add sidesheet component, use it for TOC and AIChat
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Set <html lang> based on translated language
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Support social links
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/embed": patch
---
Fix signed JWT token usage
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Fix OpenAPI example not showing
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Align styling of PageLinkItem and PageDocumentItem
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/openapi-parser": patch
---
Update scalar dependencies.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Support `defaultExpanded` in Expandable block
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Better conditional display of page actions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track opening of search results for records and in the assistant.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Improve OpenAPI server URL validation
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Header & TOC tweaks
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix ordered list item index calculation
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Fix Scalar Try it button justification
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Standardise toggle chevrons across the app
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Grow InlineActionButton to take available width on line
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/icons": patch
---
Simplify installation by using public fontawesome mirror.
-6
View File
@@ -1,6 +0,0 @@
---
"@gitbook/browser-types": patch
"gitbook": patch
---
Add support for custom cookie banner registration
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Better handle huge section groups
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Fix Embed options in React
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Add OpenAPI 3.1 nullable array support
@@ -52,6 +52,7 @@ 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_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
@@ -51,6 +51,7 @@ 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_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
+4 -4
View File
@@ -137,7 +137,7 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
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)
@@ -161,7 +161,7 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
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
@@ -185,7 +185,7 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
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)
@@ -209,7 +209,7 @@ jobs:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
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)
+3
View File
@@ -0,0 +1,3 @@
# AGENTS Instructions
- After making code changes, run `bun format`.
+822 -182
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -7,8 +7,8 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.6",
"vercel": "^39.4.2"
"turbo": "^2.8.10",
"vercel": "^50.23.2"
},
"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.164.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+15
View File
@@ -1,5 +1,20 @@
# @gitbook/browser-types
## 0.1.5
### Patch Changes
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
- 2e495cb: Add Global Privacy Control (GPC) support
## 0.1.4
### Patch Changes
- 7a11861: Add support for custom cookie banner registration
- Updated dependencies [6f1db32]
- @gitbook/icons@0.4.3
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -9,7 +9,7 @@
}
},
"sideEffects": false,
"version": "0.1.3",
"version": "0.1.5",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
+10 -3
View File
@@ -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 {
+15
View File
@@ -1,5 +1,20 @@
# @gitbook/embed
## 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
+43 -6
View File
@@ -50,7 +50,8 @@ GitBook('configure', {
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
tools: [/* ... */],
closeButton: true,
});
```
@@ -100,7 +101,8 @@ frame.configure({
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
tools: [/* ... */],
closeButton: true
});
// Listen to events
@@ -133,6 +135,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
}
]}
tools={[/* ... */]}
closeButton
/>
</GitBookProvider>
```
@@ -223,15 +226,49 @@ 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.
### `actions`
Available in: Standalone script, NPM package, React components
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.2.0",
"version": "0.2.2",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
+5
View File
@@ -67,6 +67,11 @@ export type GitBookEmbeddableConfiguration = {
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
/**
* Display a close button inside the assistant.
*/
closeButton?: boolean;
};
/**
+3 -1
View File
@@ -25,6 +25,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
closeButton = false,
} = props;
const frameRef = useRef<HTMLIFrameElement>(null);
@@ -46,8 +47,9 @@ export function GitBookFrame(props: GitBookFrameProps) {
greeting,
suggestions,
tools,
closeButton,
});
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs]);
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs, closeButton]);
return (
<iframe
+4 -2
View File
@@ -106,6 +106,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 };
}
@@ -193,5 +197,3 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
+2 -2
View File
@@ -11,8 +11,8 @@
},
"sideEffects": false,
"dependencies": {
"acorn": "^8.14.0",
"acorn-loose": "8.4.0",
"acorn": "^8.15.0",
"acorn-loose": "^8.5.2",
"acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0",
+66
View File
@@ -1,5 +1,71 @@
# gitbook
## 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({
-1
View File
@@ -232,7 +232,6 @@ const testCases: TestsCase[] = [
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
+134 -34
View File
@@ -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
@@ -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,13 @@ const testCases: TestsCase[] = [
{
name: 'Stepper',
url: 'blocks/stepper',
run: waitForCookiesDialog,
},
{
name: 'Columns',
url: 'blocks/columns',
run: waitForCookiesDialog,
},
{ name: 'Columns', url: 'blocks/columns' },
],
},
{
@@ -926,7 +1018,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 +1640,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 +2118,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
+4 -4
View File
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - e2e - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - e2e - single-page', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
const printBtn = page.getByTestId('print-button');
await expect(printBtn).toBeVisible();
await argosScreenshot(page, 'pdf - all pages', {
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
viewports: ['macbook-13'],
argosCSS: `
/* Hide Intercom */
+12 -2
View File
@@ -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,
});
}
+2
View File
@@ -4,6 +4,7 @@
* @type {import('next').NextConfig}
*/
const nextConfig = {
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -25,6 +26,7 @@ const nextConfig = {
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
@@ -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',
},
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.21.3",
"version": "0.22.1",
"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,11 @@
"usehooks-ts": "catalog:",
"warn-once": "^0.1.1",
"zod": "^3",
"zustand": "^5.0.3",
"negotiator": "^1.0.0"
"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 +107,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"
},
@@ -18,6 +18,14 @@ export async function GET(
<html>
<head>
<meta name="color-scheme" content="light dark">
<style type="text/css">
.control {
display: none;
}
.custom-trigger .control {
display: inline-flex;
}
</style>
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -105,17 +113,29 @@ export async function GET(
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
<script>
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
});
window.GitBook('open');
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
window.GitBook('configure', {
suggestions: [
'Help me get started',
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'docs'],
closeButton: useCustomTrigger
});
if (useCustomTrigger) {
document.body.classList.add('custom-trigger');
window.GitBook('hide');
} else {
window.GitBook('open');
}
</script>
</html>
`,
@@ -24,10 +24,22 @@ export async function GET(
(function () {
const w = window;
const gb = w.GitBook;
function getScriptSearchParams() {
const script = document.currentScript;
if (!script) return new URLSearchParams();
const url = new URL(script.src);
return url.searchParams;
}
const searchParams = getScriptSearchParams()
const token = searchParams.get('jwt_token');
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
const initFrameOptions = token ? { visitor: { token } } : undefined;
if (typeof gb === "function") {
gb('init', initOptions);
gb('init', initOptions, initFrameOptions);
} else {
var d = document;
@@ -40,7 +52,7 @@ export async function GET(
};
w.GitBook = g;
g('init', initOptions);
g('init', initOptions, initFrameOptions);
const load = function () {
const style = document.createElement('link');
@@ -32,17 +32,30 @@ async function handler(
);
return {
content: results.flatMap((spaceResult) => {
content: results.flatMap((result) => {
if (result.type === 'record') {
return {
type: 'text',
text: [
`Title: ${result.title}`,
`Link: ${result.url}`,
result.description ? `Content: ${result.description}` : '',
]
.filter(Boolean)
.join('\n'),
};
}
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceResult.id
(siteSpace) => siteSpace.space.id === result.id
);
const spaceURL = found?.siteSpace.urls.published;
if (!spaceURL) {
return [];
}
return spaceResult.pages.map((pageResult) => {
return result.pages.map((pageResult) => {
const pageURL = linker.toAbsoluteURL(
linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, pageResult.path)
+2
View File
@@ -11,6 +11,7 @@ import {
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_SECRET,
GITBOOK_URL,
GITBOOK_USER_AGENT,
@@ -25,6 +26,7 @@ export async function GET(_req: NextRequest) {
GITBOOK_APP_URL,
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_OAUTH_SERVER_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
@@ -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,
}
);
@@ -11,7 +11,8 @@ import {
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { useTrackEvent } from '../Insights';
import { getVisitor, useTrackEvent } from '../Insights';
import { getSession } from '../Insights/sessions';
import { integrationsAssistantTools } from '../Integrations';
import { useSetSearchState } from '../Search';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
@@ -269,6 +270,10 @@ export function AIChatProvider(props: {
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
},
tools: integrationTools.map((tool) => ({
name: tool.name,
description: tool.description,
@@ -87,7 +87,7 @@ 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>
@@ -106,7 +106,7 @@ export function AIChat() {
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<EmbeddableFrameBody className="not-embed:px-4">
<AIChatBody
chatController={chatController}
chat={chat}
@@ -224,7 +224,7 @@ export function AIChatBody(props: {
<>
<ScrollContainer
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
contentClassName="p-4 gutter-stable flex flex-col gap-4"
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,7 +272,7 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex flex-col gap-2 px-4 pb-4">
<div className="flex flex-col gap-2 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -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
@@ -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"
@@ -17,18 +17,19 @@ export function AIChatFollowupSuggestions(props: {
return (
<div className="flex grow flex-col">
<div
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
data-testid="ai-chat-followup-suggestions"
>
{chat.followUpSuggestions.map((suggestion, index) => (
<Button
truncate={false}
data-testid="ai-chat-followup-suggestion"
key={index}
onClick={() => {
chatController.postMessage({ message: suggestion });
}}
label={suggestion}
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
className="starting:h-0 max-w-full origin-left animate-blur-in-slow border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 hover:bg-primary-hover"
size="small"
variant="blank"
style={{
@@ -1,10 +1,9 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig } from 'motion/react';
import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -14,7 +13,6 @@ import {
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSeparator,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
@@ -22,6 +20,7 @@ import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
@@ -41,7 +40,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
onToggleMinify,
});
const visitorSession = useVisitorSession();
const visitor = useVisitor();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
@@ -81,7 +80,7 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) {
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitorSession?.organizationId === context.organizationId) {
if (visitor?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
@@ -132,7 +131,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -141,14 +140,13 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle
subtitle={
<>
<DateRelative value={changeRequest.updatedAt} /> by {author}
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -207,18 +205,11 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
</>
}
/>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Open commit in Git client */}
<ToolbarButton
@@ -280,22 +271,11 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
@@ -305,18 +285,18 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open site in GitBook */}
<ToolbarButton
title="Open site in GitBook"
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="gears"
icon="folder-gear"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize in GitBook"
title="Customize site"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
@@ -327,7 +307,7 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights in GitBook"
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
@@ -361,13 +341,13 @@ function EditPageButton(props: {
return (
<ToolbarButton
title="Edit in GitBook"
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pencil"
icon="pen-to-square"
motionValues={motionValues}
/>
);
@@ -8,10 +8,21 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
// Params for the expanding arc defined separately for easier tweaking.
const ARC_PARAMS = {
arcWidth: 505,
arcHeight: 400,
arcRadius: 34,
startDistance: -240,
spreadDistance: 45,
fromDistance: -286,
fromSpread: 0,
durationSeconds: 0.6,
staggerMs: 80,
baseRotationDeg: 95,
rotationStepDeg: 18,
offsetAnchorY: 40,
} as const;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
@@ -23,30 +34,56 @@ interface HideToolbarButtonProps {
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const controls = useToolbarControls();
const closingTimeoutRef = useRef(0);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const closeArcMenu = React.useCallback(() => {
if (!open || closing) return;
setClosing(true);
// Clear any existing timeout
window.clearTimeout(closingTimeoutRef.current);
// Wait for the exit animation to complete before unmounting
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
closingTimeoutRef.current = window.setTimeout(() => {
setOpen(false);
setClosing(false);
}, totalDuration);
}, [open, closing]);
// Clean up timeout on unmount
React.useEffect(() => {
return () => window.clearTimeout(closingTimeoutRef.current);
}, []);
const handleClickOutsideArcMenu = (event: Event) => {
// Don't close the arc if we are clicking on the button itself
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
setOpen(false);
closeArcMenu();
};
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
// Close arc menu on scroll or resize
React.useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
const handleClose = () => closeArcMenu();
window.addEventListener('scroll', handleClose, { passive: true });
window.addEventListener('resize', handleClose, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
return () => {
window.removeEventListener('scroll', handleClose);
window.removeEventListener('resize', handleClose);
};
}, [open, closeArcMenu]);
const items = [
controls?.minimize
@@ -84,18 +121,36 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
title={open ? undefined : 'Hide toolbar'}
className={
open || closing
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
: undefined
}
onClick={() => {
setOpen((v) => !v);
if (open || closing) {
closeArcMenu();
} else {
setOpen(true);
}
}}
motionValues={motionValues}
icon="eye-slash"
icon="gear"
>
{/* Expanding arc menu */}
{open && (
{(open || closing) && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
style={
{
...sharedMotionStyle,
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
'--start-distance': `${ARC_PARAMS.startDistance}px`,
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
} as React.CSSProperties
}
>
<div
className={tcls(
@@ -107,11 +162,12 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
key={item.icon}
staggerIndex={closing ? index : items.length - 1 - index}
key={item.id}
closing={closing}
{...item}
onClick={() => {
setOpen(false);
closeArcMenu();
item.onClick?.();
}}
/>
@@ -127,13 +183,13 @@ type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
onClick?: () => void;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
closing?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
@@ -143,40 +199,36 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
closing = false,
label,
disabled,
className,
onClick = () => {},
onClick,
icon,
iconClassName,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = calculateRotation();
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
return (
<div className="pointer-events-none">
<button
type="button"
onClick={() => {
onClick();
}}
onClick={onClick ? () => onClick() : undefined}
style={
{
'--from-offset-distance': fromOffset,
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
offsetDistance: fromOffset,
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
offsetRotate: 'auto 90deg',
} as React.CSSProperties
}
className={tcls(
@@ -185,48 +237,43 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
<div className="flex items-center gap-2">
<div
className={tcls(
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls(
'size-3.5 shrink-0 group-hover:scale-110',
iconClassName
)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
)}
>
{label}
</span>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</div>
);
@@ -17,7 +17,7 @@
.arcMenuItem {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: forwards;
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
@@ -28,13 +28,53 @@
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
to {
offset-distance: var(--target-offset-distance);
transform: scale(1);
opacity: 1;
offset-rotate: auto var(--rotation-offset);
}
}
@keyframes hide-toolbar-arc-exit {
from {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
}
to {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
}
.arcMenuItemExit {
animation-name: hide-toolbar-arc-exit;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.3s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
offset-path: border-box;
offset-anchor: 0% 0%;
offset-rotate: auto var(--rotation-offset, 0deg);
}
/* ── Arc label fade ── */
@keyframes arc-label-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes arc-label-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.arcLabelFadeIn {
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
}
.arcLabelFadeOut {
animation: arc-label-fade-out 0.2s ease-out forwards;
}
@@ -9,6 +9,7 @@ import {
import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { ToolbarVisibilityHint } from './ToolbarVisibilityHint';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
@@ -19,15 +20,20 @@ import { useMagnificationEffect } from './useMagnificationEffect';
const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
const PILL_STYLE = {
'--toolbar-bg': '#1f1d1b',
borderRadius: '100px', // Set on `style` so Framer Motion can correct for distortions
zIndex: 1, // Ensure pill stacks above the peek label sibling
} as React.CSSProperties;
interface ToolbarProps {
label: React.ReactNode;
children: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, label, minified, onMinifiedChange } = props;
const { children, minified, onMinifiedChange } = props;
const controls = useToolbarControls();
const [isReady, setIsReady] = React.useState(false);
const autoExpandTriggeredRef = React.useRef(false);
@@ -35,6 +41,10 @@ export function Toolbar(props: ToolbarProps) {
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
// Track when the pill's layout animation finishes so the hint label
// only appears once the toolbar is fully expanded.
const [showHint, setShowHint] = React.useState(!minified);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
@@ -82,6 +92,10 @@ export function Toolbar(props: ToolbarProps) {
// Any manual expansion should stop the logo animation so the icon stays in its
// “settled” state once the toolbar is open.
setShouldAnimateLogo(false);
} else {
// Hide the hint immediately when minimizing — it will reappear
// once the next expand animation completes.
setShowHint(false);
}
}, [minified]);
@@ -91,47 +105,57 @@ export function Toolbar(props: ToolbarProps) {
}
return (
<Tooltip label={label}>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<ToolbarVisibilityHint show={showHint} />
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
onLayoutAnimationComplete={() => {
if (!minified) {
setShowHint(true);
}
}}
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={PILL_STYLE}
>
{/* Logo — double-click to minimize */}
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'backdrop-blur-sm',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
)}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
onDoubleClick={(e) => {
if (minified) return;
e.stopPropagation();
window.getSelection()?.removeAllRanges();
onMinifiedChange(true);
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{!minified ? children : null}
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
);
}
@@ -198,62 +222,51 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
} = props;
const reduceMotion = useReducedMotion();
const anchor = (
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
{title ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
);
});
@@ -300,10 +313,6 @@ function ToolbarButtonWrapper(props: {
});
}
export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />;
}
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
return (
<div className="flex items-center gap-1 text-xs ">
@@ -328,7 +337,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return (
<motion.span
{...getCopyVariants(1)}
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
className="max-w-[20ch] truncate font-bold text-neutral-1 dark:text-neutral-12"
>
{props.title}
</motion.span>
@@ -339,7 +348,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
@@ -0,0 +1,94 @@
'use client';
import React from 'react';
import { useLanguage } from '@/intl/client';
import { AnimatePresence, motion } from 'motion/react';
import { formatDateTime, formatDateWeekday, formatRelative } from '../utils/dates';
type DateFormat = 'relative' | 'weekday' | 'full';
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
/**
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
*/
export function ToolbarDate(props: { value: string }) {
const { value } = props;
const language = useLanguage();
const [formatIndex, setFormatIndex] = React.useState(0);
const [hovered, setHovered] = React.useState(false);
const format = DATE_FORMATS[formatIndex] as DateFormat;
const formatted = React.useMemo(() => {
const date = new Date(value);
switch (format) {
case 'relative':
return formatRelative(language.locale, Date.now() - date.getTime());
case 'weekday':
return formatDateWeekday(date, language.locale);
case 'full':
return formatDateTime(date, language.locale);
}
}, [format, language.locale, value]);
return (
<motion.div
className="inline-flex items-center gap-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Vertical dots indicating cycleable formats */}
<AnimatePresence>
{hovered && (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="overflow-hidden"
>
<div className="flex flex-col items-center gap-[1px]">
{DATE_FORMATS.map((fmt, i) => (
<motion.span
key={fmt}
animate={{
opacity: i === formatIndex ? 1 : 0.3,
scale: i === formatIndex ? 1 : 0.75,
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="block size-[3px] rounded-full bg-current"
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<motion.time
layout="position"
data-visual-test="transparent"
suppressHydrationWarning={true}
dateTime={value}
onClick={(e) => {
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="relative inline-flex cursor-pointer select-none overflow-hidden font-semibold transition-colors hover:text-white" // `text-white` is used insead of dark-mode adapting values because the date is always in a dark-tinted toolbar
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={formatIndex}
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
{formatted}
</motion.span>
</AnimatePresence>
</motion.time>
</motion.div>
);
}
@@ -0,0 +1,51 @@
'use client';
import { motion } from 'motion/react';
import React from 'react';
import { getVisibilityHintDismissed, setVisibilityHintDismissed } from './utils';
interface ToolbarVisibilityHintProps {
show: boolean;
}
export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
const { show } = props;
const [dismissed, setDismissed] = React.useState(() =>
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
);
if (dismissed) {
return null;
}
return (
<motion.div
initial={false}
animate={{
y: show ? 0 : 10,
opacity: show ? 1 : 0,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
Only visible to your GitBook organization
</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
@@ -101,7 +101,8 @@ function Code(props: MarkedLeafProps<DocumentMarkCode>) {
// Text size is proportional to the font-size of the parent element
'text-[.875em]',
// We ensure that the code is not making the parent bigger, especially in headings
'leading-[calc(max(1.20em,1.25rem))]'
'leading-[calc(max(1.20em,1.25rem))]',
'break-all'
)}
>
{props.children}
@@ -1,7 +1,9 @@
import { formatDateFull, formatDateShort, formatNumericDate } from '@/components/utils/dates';
import { getRevisionTags, resolveBlockTags } from '@/lib/tags';
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockUpdate, DocumentBlockUpdates } from '@gitbook/api';
import { assert } from 'ts-essentials';
import { Tag } from '../Tag';
import type { BlockProps } from './Block';
import { Blocks } from './Blocks';
@@ -35,13 +37,12 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
short: formatDateShort(parsedDate),
}[dateFormat];
// Resolve tags from the block data using revision-level tag definitions
const revisionTags = getRevisionTags(contextProps.context.contentContext?.revision);
const resolvedTags = resolveBlockTags(block.data.tags, revisionTags);
return (
<div
className={tcls(
'relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-12 xl:gap-20',
style
)}
>
<div className={tcls('relative flex flex-col gap-2 md:flex-row md:gap-4 lg:gap-8', style)}>
<div
className={tcls(
// Date is only sticky on larger screens when we use flex-row layout, with 0px fallback to prevent flicker and flaky tests before JS sets the variable
@@ -55,6 +56,13 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
>
{displayDate}
</time>
{resolvedTags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{resolvedTags.map((tag) => (
<Tag key={tag.slug} label={tag.label} />
))}
</div>
) : null}
</div>
<Blocks
{...contextProps}
@@ -10,7 +10,7 @@ export function Updates(props: BlockProps<DocumentBlockUpdates>) {
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style={[style, 'flex flex-col gap-20']}
style={[style, 'updates-block flex flex-col gap-20']}
/>
);
}
@@ -10,8 +10,9 @@ import {
} from '@/components/AIChat';
import { useLanguage } from '@/intl/client';
import * as api from '@gitbook/api';
import React from 'react';
import React, { use, useMemo } from 'react';
import { useTrackEvent } from '../Insights';
import { LinkContext, type LinkContextType } from '../primitives';
import {
EmbeddableFrame,
EmbeddableFrameBody,
@@ -24,6 +25,7 @@ import {
} from './EmbeddableFrame';
import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
EmbeddableIframeTabs,
useEmbeddableConfiguration,
} from './EmbeddableIframeAPI';
@@ -63,6 +65,20 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const hasDocsTab = configuration.tabs.includes('docs');
const currentLinkContext = use(LinkContext);
const linkContext: LinkContextType = useMemo(
() =>
hasDocsTab
? { ...currentLinkContext, externalTarget: '_blank' }
: {
...currentLinkContext,
isExternalClient: () => true,
isExternalServer: () => true,
externalTarget: '_blank',
},
[hasDocsTab, currentLinkContext]
);
return (
<EmbeddableFrame>
@@ -74,6 +90,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
siteTitle={siteTitle}
/>
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain data-testid="ai-chat">
<EmbeddableFrameHeader>
@@ -94,12 +111,14 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
greeting={configuration.greeting}
/>
<LinkContext value={linkContext}>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
greeting={configuration.greeting}
/>
</LinkContext>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame>
@@ -18,7 +18,11 @@ import {
EmbeddableFrameSidebar,
EmbeddableFrameTitle,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons, EmbeddableIframeTabs } from './EmbeddableIframeAPI';
import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
EmbeddableIframeTabs,
} from './EmbeddableIframeAPI';
export const dynamic = 'force-static';
@@ -48,11 +52,12 @@ export async function EmbeddableDocsPage(
siteTitle={context.site.title}
/>
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain data-testid="embed-docs-page">
<div className="relative flex not-hydrated:animate-blur-in-slow flex-col">
<EmbeddableFrameHeader>
<HeaderMobileMenu className="-ml-2 page-no-toc:hidden" />
<div className="relative flex flex-col border-tint-subtle border-b theme-bold:bg-header-background">
<EmbeddableFrameHeader className="theme-bold:text-header-link">
<HeaderMobileMenu className="-ml-2 page-no-toc:hidden theme-bold:text-header-link hover:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link lg:hidden" />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>{context.site.title}</EmbeddableFrameTitle>
</EmbeddableFrameHeaderMain>
@@ -69,7 +74,7 @@ export async function EmbeddableDocsPage(
</EmbeddableFrameHeader>
{context.sections ? (
<SiteSectionTabs
className="-mt-2 border-tint-subtle border-b"
className="not-theme-bold:-mt-2 theme-bold:bg-tint-base"
sections={encodeClientSiteSections(context, context.sections)}
/>
) : null}
@@ -77,9 +82,9 @@ export async function EmbeddableDocsPage(
<EmbeddableFrameBody>
<ScrollContainer
orientation="vertical"
className="not-hydrated:animate-blur-in-slow"
contentClassName="p-4"
leading={{ fade: !context.sections, button: true }}
className="-mx-4 not-hydrated:animate-blur-in-slow"
contentClassName="flex-row px-4"
leading={{ fade: false, button: true }}
trailing={{ fade: false, button: true }}
>
<TableOfContents context={context} withTrademark={false} />
@@ -11,6 +11,7 @@ export function EmbeddableDocsPageControlButtons(props: { href: string }) {
<Button
icon="arrow-up-right-from-square"
data-testid="embed-docs-page-open-in-new-tab"
className="theme-bold:text-header-link hover:bg-tint-hover hover:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link"
label={tString(language, 'open_in_new_tab')}
href={href}
target="_blank"
@@ -1,5 +1,7 @@
import { tcls } from '@/lib/tailwind';
import { type ClassValue, tcls } from '@/lib/tailwind';
import React from 'react';
import { CONTAINER_STYLE } from '../layout';
import { Button } from '../primitives';
export type EmbeddableFrameProps = React.ComponentProps<'div'> & {
children: React.ReactNode;
@@ -40,11 +42,18 @@ export function EmbeddableFrameMain(props: React.ComponentProps<'div'>) {
export function EmbeddableFrameHeader(props: {
children: React.ReactNode;
className?: ClassValue;
}) {
const { children } = props;
const { children, className } = props;
return (
<div className="relative z-10 flex not-hydrated:animate-blur-in-slow select-none items-center gap-2 px-4 py-2.5 text-tint-strong">
<div
className={tcls(
'relative z-10 flex w-full not-hydrated:animate-blur-in-slow select-none items-center gap-2 py-2.5 text-tint-strong',
CONTAINER_STYLE,
className
)}
>
{children}
</div>
);
@@ -60,10 +69,21 @@ export function EmbeddableFrameHeaderMain(props: {
export function EmbeddableFrameBody(props: {
children: React.ReactNode;
className?: ClassValue;
}) {
const { children } = props;
const { children, className } = props;
return <div className="flex flex-1 flex-col overflow-hidden">{children}</div>;
return (
<div
className={tcls(
'flex w-full flex-1 flex-col overflow-hidden',
CONTAINER_STYLE,
className
)}
>
{children}
</div>
);
}
export function EmbeddableFrameTitle(props: {
@@ -74,6 +94,10 @@ export function EmbeddableFrameTitle(props: {
return <div className="font-bold">{children}</div>;
}
export function EmbeddableFrameClose() {
return <Button iconOnly icon="x" className="absolute top-1 right-1" />;
}
export function EmbeddableFrameSubtitle(props: {
children: React.ReactNode;
className?: string;
@@ -96,7 +120,7 @@ export function EmbeddableFrameSidebar(props: { children: React.ReactNode }) {
const { children } = props;
return (
<div className="flex w-15 shrink-0 origin-top not-hydrated:animate-blur-in-slow flex-col gap-2 overflow-hidden border-tint-solid/3 border-r bg-tint-solid/1 p-2 transition-all transition-discrete duration-300 empty:hidden empty:w-0 empty:px-0">
<div className="flex w-15 shrink-0 origin-top not-hydrated:animate-blur-in-slow flex-col gap-2 overflow-hidden border-tint-solid/3 border-r bg-tint-solid/1 p-2 transition-all transition-discrete duration-300 empty:hidden empty:w-0 empty:px-0 motion-reduce:transition-none">
{children}
</div>
);
@@ -1,7 +1,6 @@
'use client';
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
import { createChannel } from 'bidc';
import React, { useEffect, useRef } from 'react';
import { useAI, useAIChatController } from '@/components/AI';
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives';
import { getChannel } from './channel';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
tabs: [],
@@ -50,13 +50,11 @@ export function EmbeddableIframeAPI(props: {
}, [chatController]);
React.useEffect(() => {
if (window.parent === window) {
const channel = getChannel();
if (!channel) {
return;
}
log('[gitbook] create channel with parent window');
const channel = createChannel();
channel.receive((payload) => {
const { baseURL, router, chatController } = refs.current;
const message = payload as ParentToFrameMessage;
@@ -91,11 +89,6 @@ export function EmbeddableIframeAPI(props: {
}
}
});
return () => {
log('[gitbook] cleanup');
channel.cleanup();
};
}, []);
return null;
@@ -204,7 +197,7 @@ export function EmbeddableIframeTabs(props: {
}, [tabs, baseURL, router, active]);
return tabs.length > 1 || actions.length > 0 ? (
<div className="flex flex-col gap-2" ref={ref}>
<div className="flex flex-col items-center gap-2" ref={ref}>
{tabs.map((tab) => (
<Button
key={tab.key}
@@ -227,3 +220,32 @@ export function EmbeddableIframeTabs(props: {
</div>
) : null;
}
export function EmbeddableIframeCloseButton() {
const { closeButton } = useEmbeddableConfiguration();
if (!closeButton) {
return null;
}
return (
<div className="flex flex-1 flex-col justify-end">
<Button
label="Close"
variant="blank"
size="large"
icon="xmark"
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={() => {
getChannel()?.send({ type: 'close' });
}}
tooltipProps={{
contentProps: {
side: 'right',
},
}}
/>
</div>
);
}
@@ -29,7 +29,7 @@ export async function EmbeddableRootLayout({
children,
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
return (
<CustomizationRootLayout context={context}>
<CustomizationRootLayout context={context} htmlClassName="embed">
<SiteLayoutClientContexts
forcedTheme={
context.customization.themes.toggeable
@@ -38,6 +38,7 @@ export async function EmbeddableRootLayout({
}
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
>
<AIContextProvider
aiMode={context.customization.ai.mode}
@@ -0,0 +1,19 @@
'use client';
import { createChannel } from 'bidc';
import memoize from 'memoizee';
// biome-ignore lint/suspicious/noExplicitAny: expected
export function log(...data: any[]) {
// biome-ignore lint/suspicious/noConsole: expected
console.log(...data);
}
export const getChannel = memoize(() => {
if (typeof window === 'undefined' || window.parent === window) {
return null;
}
log('[gitbook] create channel with parent window');
return createChannel();
});
@@ -28,14 +28,14 @@ export function Footer(props: { context: GitBookSiteContext }) {
return (
<footer
id="site-footer"
data-gb-site-footer
className={tcls(
'border-tint-subtle border-t',
// If the footer only contains a mode toggle, we only show it on smaller screens
mobileOnly ? 'xl:hidden' : null
)}
>
<div className="motion-safe:transition-[padding] motion-safe:duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<div
className={tcls(
CONTAINER_STYLE,
@@ -62,7 +62,7 @@ const SOCIAL_PLATFORMS: Record<SiteSocialAccountPlatform, SocialPlatformData> =
[SiteSocialAccountPlatform.Reddit]: {
label: 'Reddit',
icon: 'reddit',
href: 'https://reddit.com/@$handle',
href: 'https://reddit.com/$handle',
},
[SiteSocialAccountPlatform.Bluesky]: {
label: 'Bluesky',
@@ -35,7 +35,7 @@ export function Header(props: {
return (
<header
id="site-header"
data-gb-site-header
className={tcls(
'flex',
'flex-col',
@@ -63,14 +63,14 @@ export function Header(props: {
>
<div
className={tcls(
'theme-bold:bg-header-background',
'theme-bold:shadow-[0px_1px_0px]',
'theme-bold:shadow-tint-12/2'
'site-header:theme-bold:bg-header-background',
'site-header:theme-bold:shadow-[0px_1px_0px]',
'site-header:theme-bold:shadow-tint-12/2'
)}
>
<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
id="header-content"
data-gb-header-content
className={tcls(
'gap-4',
'lg:gap-6',
@@ -82,7 +82,7 @@ export function Header(props: {
'min-h-16',
'sm:h-16',
CONTAINER_STYLE,
'transition-[max-width] duration-300',
'transition-[max-width] duration-300 motion-reduce:transition-none',
'@container/header'
)}
>
@@ -100,9 +100,9 @@ export function Header(props: {
className={tcls(
'-ml-2',
'text-tint-strong',
'theme-bold:text-header-link',
'site-header:theme-bold:text-header-link',
'hover:bg-tint-hover',
'hover:theme-bold:bg-header-link/3',
'hover:site-header:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'page-no-toc:hidden lg:hidden'
@@ -120,13 +120,13 @@ export function Header(props: {
'justify-self-end',
'items-center',
'gap-2',
'transition-[margin] duration-300',
'transition-[margin] duration-300 motion-reduce:transition-none',
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'@2xl:grow-[0.8]',
'@4xl:basis-40',
'@2xl:max-w-[40%]',
'@2xl:max-w-[50%]',
'@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
@@ -191,7 +191,7 @@ export function Header(props: {
) ?? siteSpace
}
siteSpaces={variants.translations}
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
className="flex! site-header:theme-bold:text-header-link hover:site-header:theme-bold:bg-header-link/3 focus-visible:site-header:theme-bold:bg-header-link/3 aria-expanded:site-header:theme-bold:bg-header-link/5"
/>
) : null}
</HeaderLinks>
@@ -201,7 +201,7 @@ export function Header(props: {
</div>
{visibleSections && withSections ? (
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div className="transition-[padding] duration-300 motion-reduce:transition-none lg:chat-open:pr-80 xl:chat-open:pr-96">
<SiteSectionTabs sections={encodeClientSiteSections(context, visibleSections)}>
{variants.translations.length > 1 ? (
<TranslationsDropdown
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import { getAllBrowserCookiesMap } from '@/lib/browser';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { type SessionResponse, useVisitorSession } from './visitorId';
import { type VisitorResponse, useVisitor } from './visitorId';
export type InsightsEventName = api.SiteInsightsEvent['type'];
@@ -68,7 +68,7 @@ interface InsightsProviderProps {
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, eventUrl } = props;
const visitorSession = useVisitorSession();
const visitor = useVisitor();
const currentContent = useCurrentContent();
const eventsRef = React.useRef<{
[pathname: string]:
@@ -86,7 +86,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
*/
const flushEventsSync = useEventCallback(() => {
const session = getSession();
if (!visitorSession) {
if (!visitor) {
return;
}
@@ -107,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
visitorSession,
visitor: visitor,
sessionId: session.id,
})
);
@@ -140,10 +140,10 @@ export function InsightsProvider(props: InsightsProviderProps) {
// Flush pending events once the visitor session has been fetched
React.useEffect(() => {
if (visitorSession) {
if (visitor) {
flushEventsSync();
}
}, [visitorSession, flushEventsSync]);
}, [visitor, flushEventsSync]);
const trackEvent: TrackEventCallback = useEventCallback(
(
@@ -241,12 +241,12 @@ function transformEvents(input: {
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
visitorSession: SessionResponse;
visitor: VisitorResponse;
sessionId: string;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorSession.deviceId,
visitorId: input.visitor.deviceId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: getAllBrowserCookiesMap(),
@@ -32,11 +32,21 @@ export function isCookiesTrackingDisabled() {
return undefined;
}
let cachedIsGlobalPrivacyControlEnabled: boolean | undefined;
/**
* Return true if cookies are accepted.
* Return false if cookies are rejected or unknown.
* Get the global privacy control settings of the user.
* Return `true` if global privacy control is enabled
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/globalPrivacyControl
*/
export function hasApprovedCookies() {
const state = getBrowserCookie(GRANTED_COOKIE);
return state === 'yes';
export function isGlobalPrivacyControlEnabled(): boolean {
if (cachedIsGlobalPrivacyControlEnabled !== undefined) {
return cachedIsGlobalPrivacyControlEnabled;
}
if (typeof navigator === 'undefined' || !('globalPrivacyControl' in navigator)) {
cachedIsGlobalPrivacyControlEnabled = false;
return false;
}
cachedIsGlobalPrivacyControlEnabled = Boolean(navigator.globalPrivacyControl);
return cachedIsGlobalPrivacyControlEnabled;
}
@@ -4,37 +4,47 @@ import { createStore, useStore } from 'zustand';
import { getBrowserCookie } from '@/lib/browser';
import type { MaybePromise } from 'p-map';
import React from 'react';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
type SessionVisitorResponse = {
/**
* Visitor state when the visitor is not a signed-in GitBook user.
*/
type AnyVisitorResponse = {
/**
* A random identifier for the visitor. Resets if the user clears their cookies.
*/
deviceId: string;
userId?: undefined;
organizationId?: undefined;
};
type SessionUserResponse = SessionVisitorResponse & {
/**
* Visitor state when the visitor is also a signed-in GitBook user.
*/
type VisitorUserResponse = AnyVisitorResponse & {
userId: string;
organizationId: string;
};
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
export type VisitorResponse = AnyVisitorResponse | VisitorUserResponse;
const visitorSessionStore = createStore<{
session: SessionResponse | null;
pendingSession: Promise<SessionResponse> | null;
const visitorStore = createStore<{
visitor: VisitorResponse | null;
pendingVisitor: Promise<VisitorResponse> | null;
}>(() => ({
session: null,
pendingSession: null,
visitor: null,
pendingVisitor: null,
}));
/**
* Fetch, synchronize the visitor sesion with GitBook.
* Fetch, synchronize the visitor with GitBook.
*/
export function VisitorSessionProvider(
export function VisitorProvider(
props: React.PropsWithChildren<{
appURL: string;
visitorCookieTrackingEnabled: boolean;
@@ -43,15 +53,15 @@ export function VisitorSessionProvider(
const { appURL, visitorCookieTrackingEnabled, children } = props;
React.useEffect(() => {
const state = visitorSessionStore.getState();
if (state.pendingSession || state.session) {
const state = visitorStore.getState();
if (state.pendingVisitor || state.visitor) {
return;
}
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
visitorSessionStore.setState({ pendingSession, session: null });
pendingSession.then((session) => {
visitorSessionStore.setState({ pendingSession: null, session });
const pendingVisitor = fetchGlobalVisitor({ appURL, visitorCookieTrackingEnabled });
visitorStore.setState({ pendingVisitor, visitor: null });
pendingVisitor.then((visitor) => {
visitorStore.setState({ pendingVisitor: null, visitor });
});
}, [appURL, visitorCookieTrackingEnabled]);
@@ -61,29 +71,46 @@ export function VisitorSessionProvider(
/**
* Hook to get the current visitor session.
*/
export function useVisitorSession() {
return useStore(visitorSessionStore, (state) => state.session);
export function useVisitor() {
return useStore(visitorStore, (state) => state.visitor);
}
/**
* Get the current visitor ID.
*/
export function getVisitor(): MaybePromise<VisitorResponse> {
const state = visitorStore.getState();
if (state.visitor) {
return state.visitor;
}
if (state.pendingVisitor) {
return state.pendingVisitor;
}
return {
deviceId: getProposedVisitorId(),
};
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchSession({
async function fetchGlobalVisitor({
appURL,
visitorCookieTrackingEnabled,
}: {
appURL: string;
visitorCookieTrackingEnabled: boolean;
}): Promise<SessionResponse> {
}): Promise<VisitorResponse> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return {
deviceId: generateRandomId(),
deviceId: getProposedVisitorId(),
};
}
const { existing, proposedId } = getSessionCookie();
const { existing, proposedId } = getVisitorFromCookies();
if (existing) {
// If the cookie already exists, we'll just use that. Avoids a server request.
@@ -100,10 +127,11 @@ async function fetchSession({
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
signal: AbortSignal.timeout(500),
});
const session = (await resp.json()) as SessionResponse;
return session;
const visitor = (await resp.json()) as VisitorResponse;
return visitor;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return {
@@ -112,19 +140,28 @@ async function fetchSession({
}
}
function getSessionCookie(): { existing: SessionResponse | null; proposedId: string } {
const proposed = generateRandomId();
/**
* Extract the current visitor from the cookies.
*/
function getVisitorFromCookies(): { existing: VisitorResponse | null; proposedId: string } {
const proposedId = getProposedVisitorId();
const value = getBrowserCookie(VISITORID_COOKIE);
if (!(value && typeof value === 'string')) {
return { existing: null, proposedId: proposed };
return { existing: null, proposedId };
}
try {
const parsed = JSON.parse(value) as SessionResponse;
return { existing: parsed, proposedId: proposed };
const parsed = JSON.parse(value) as VisitorResponse;
return { existing: parsed, proposedId };
} catch {
// Ignore legacy __session cookie format
// and we'll renew it with the server using the previous one as a proposed ID
return { existing: null, proposedId: proposed };
return { existing: null, proposedId };
}
}
let proposedIdCache: string | null = null;
function getProposedVisitorId(): string {
proposedIdCache ??= generateRandomId();
return proposedIdCache;
}
@@ -1,6 +1,11 @@
'use client';
import { hasApprovedCookies, setCookiesTracking } from '@/components/Insights';
import {
isCookiesTrackingDisabled,
isGlobalPrivacyControlEnabled,
setCookiesTracking,
} from '@/components/Insights';
import { isAIUserAgent } from '@/lib/browser';
import type {
GitBookGlobal,
GitBookIntegrationEvent,
@@ -86,6 +91,11 @@ if (typeof window !== 'undefined') {
};
},
registerCookieBanner: (handler) => {
// Do not register cookie banner for AI UserAgents (crawlers, AI-assisted browsers)
if (isAIUserAgent()) {
return;
}
customCookieBannerStore.setState((state) => ({
...state,
hasCustomCookieBanner: true,
@@ -102,7 +112,8 @@ if (typeof window !== 'undefined') {
},
});
},
hasApprovedCookies: hasApprovedCookies,
isCookiesTrackingDisabled: isCookiesTrackingDisabled,
isGlobalPrivacyControlEnabled: isGlobalPrivacyControlEnabled,
};
window.GitBook = gitbookGlobal;
}
@@ -11,7 +11,7 @@ export function ImagesLoadingStatus(props: {
const { language } = props;
const state = useImagesLoadingState();
return (
<p className="text-right text-slate-500 text-xs">
<p className="text-right text-slate-500 text-xs" data-visual-test="removed">
{(() => {
switch (state.status) {
case 'pending':
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'bun:test';
import { RevisionPageType } from '@gitbook/api';
import { createLinker } from '@/lib/links';
import { createPDFLinker } from './linker';
function createDocumentPage(id: string, path: string) {
return {
id,
type: RevisionPageType.Document,
path,
pages: [],
} as any;
}
function createGroupPage(id: string, path: string) {
return {
id,
type: RevisionPageType.Group,
path,
pages: [],
} as any;
}
describe('createPDFLinker', () => {
it('creates anchor links for pages included in the PDF export', () => {
const linker = createPDFLinker(
createLinker({
host: 'docs.vectra.ai',
siteBasePath: '/',
spaceBasePath: '/deployment',
}),
[{ page: createDocumentPage('included', '') }]
);
expect(
linker.toPathForPage({
pages: [createDocumentPage('included', '')],
page: createDocumentPage('included', ''),
})
).toBe('#page-included');
});
it('includes anchors in in-document links for pages included in the PDF export', () => {
const linker = createPDFLinker(
createLinker({
host: 'docs.vectra.ai',
siteBasePath: '/',
spaceBasePath: '/deployment',
}),
[{ page: createDocumentPage('included', '') }]
);
expect(
linker.toPathForPage({
pages: [createDocumentPage('included', '')],
page: createDocumentPage('included', ''),
anchor: 'section1',
})
).toBe('#page-included-section1');
});
it('keeps links to non-exported pages on the published domain', () => {
const baseLinker = createLinker({
host: 'docs.vectra.ai',
siteBasePath: '/',
spaceBasePath: '/deployment',
});
const linker = createPDFLinker(baseLinker, [{ page: createDocumentPage('included', '') }]);
expect(
linker.toPathForPage({
pages: [
createDocumentPage('included', ''),
createDocumentPage('outside', 'respond'),
],
page: createDocumentPage('outside', 'respond'),
})
).toBe('https://docs.vectra.ai/deployment/respond');
});
it('returns a local placeholder link for group pages not included in the PDF export', () => {
const baseLinker = createLinker({
host: 'docs.vectra.ai',
siteBasePath: '/',
spaceBasePath: '/deployment',
});
const linker = createPDFLinker(baseLinker, [{ page: createDocumentPage('included', '') }]);
expect(
linker.toPathForPage({
pages: [
createDocumentPage('included', ''),
createGroupPage('outside-group', 'outside'),
],
page: createGroupPage('outside-group', 'outside'),
})
).toBe('#');
});
});
@@ -1,10 +1,8 @@
import type { GitBookSiteContext, GitBookSpaceContext } from '@/lib/context';
import type { GitBookLinker } from '@/lib/links';
import {
type Revision,
type RevisionPageDocument,
type RevisionPageGroup,
RevisionPageType,
type SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
type Space,
@@ -26,6 +24,7 @@ import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { PDFPrintControls } from './PDFPrintControls';
import { PageControlButtons } from './PageControlButtons';
import { createPDFLinker, getPagePDFContainerId } from './linker';
import './pdf.css';
import { sanitizeGitBookAppURL } from '@/lib/app';
import { getPageDocument } from '@/lib/data';
@@ -67,20 +66,7 @@ export async function PDFPage(props: {
);
// Build a linker that create anchor links for the pages rendered in the PDF page.
const linker: GitBookLinker = {
...baseContext.linker,
toPathForPage(input) {
if (pages.some((p) => p.page.id === input.page.id)) {
return `#${getPagePDFContainerId(input.page, input.anchor)}`;
}
if (input.page.type === RevisionPageType.Group) {
return '#';
}
// Use an absolute URL to the page
return input.page.urls.app;
},
};
const linker = createPDFLinker(baseContext.linker, pages);
const context: GitBookSpaceContext = {
...baseContext,
@@ -325,13 +311,3 @@ function selectPages(
});
return limitTo(allPages);
}
/**
* Create the HTML ID for the container of a page or a given anchor in it.
*/
function getPagePDFContainerId(
page: RevisionPageDocument | RevisionPageGroup,
anchor?: string
): string {
return `pdf-page-${page.id}${anchor ? `-${anchor}` : ''}`;
}
@@ -0,0 +1,51 @@
import type { Revision, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import { RevisionPageType } from '@gitbook/api';
import type { GitBookLinker } from '@/lib/links';
/**
* Create the HTML ID for the container of a page or a given anchor in it.
*
* @param page - The page or page group for which to create the container ID.
* @param anchor - Optional anchor within the page to include in the container ID.
* @returns The HTML ID for the container of the page or the given anchor.
*/
export function getPagePDFContainerId(
page: RevisionPageDocument | RevisionPageGroup,
anchor?: string
): string {
return `page-${page.id}${anchor ? `-${anchor}` : ''}`;
}
/**
* Create a custom linker for PDF exports.
*
* This linker generates in-document anchor links for pages that are included
* in the current PDF export, using `getPagePDFContainerId` to build the
* target element ID. For pages that are not part of the exported PDF, it
* falls back to absolute URLs pointing to the published site, preserving
* navigability for external content.
*
* @param baseLinker - The base GitBook linker used to resolve standard paths and URLs.
* @param pages - The list of pages that are included in the current PDF export.
* @returns A `GitBookLinker` configured to generate PDF-friendly links.
*/
export function createPDFLinker(
baseLinker: GitBookLinker,
pages: Array<{ page: Revision['pages'][number] }>
): GitBookLinker {
return {
...baseLinker,
toPathForPage(input) {
if (pages.some((p) => p.page.id === input.page.id)) {
return `#${getPagePDFContainerId(input.page, input.anchor)}`;
}
if (input.page.type === RevisionPageType.Group) {
return '#';
}
// For pages that are not embedded in this PDF export, keep links on the published site.
return baseLinker.toAbsoluteURL(baseLinker.toPathForPage(input));
},
};
}

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