mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-27 20:48:47 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c87cdc8af4 | |||
| aa2292036c | |||
| f7c4538b16 | |||
| b9b444c24e | |||
| 6f1b795d80 | |||
| 0803db07ef | |||
| a9adbc6370 | |||
| f49b0af3fe | |||
| 3d8e89cbe9 | |||
| f0a54bcb27 | |||
| c348583a18 | |||
| a4b238dc0c | |||
| 529afa3fac | |||
| 577bf26d1e | |||
| 4808f36718 | |||
| a100c5a6c8 | |||
| 4962662fb1 | |||
| f7c251e2d2 | |||
| 40524d4d4a | |||
| 1a7ef78fe2 | |||
| 5f3f4da2d2 | |||
| 93eea0b6d9 | |||
| 9dfa9c2db0 | |||
| d9ff25d28e | |||
| ae30a576e8 | |||
| 2e495cb28a | |||
| f517035e38 | |||
| 83bd0d74cd | |||
| 3cfefa40fd | |||
| 59ffb95417 | |||
| 2ebb18dc5a | |||
| 1deb897de1 | |||
| 11d9b80e77 | |||
| e73d9afd86 | |||
| 729501c977 | |||
| 414866cbec | |||
| 9d5e397574 | |||
| 92c9b8b056 | |||
| a73caf69ff | |||
| f72f250b16 | |||
| bd330ac133 | |||
| e31a8f4275 | |||
| cc38ec2098 | |||
| eab11e2d6f | |||
| 152490cd67 | |||
| 833b07b284 | |||
| 5f668605c2 | |||
| 9c762065f2 | |||
| 4af7df8902 | |||
| a3f854e1a8 | |||
| 4c54820055 | |||
| 22f574da9c | |||
| 426aabe39a | |||
| 64de8d2cf6 | |||
| b4b471fab6 | |||
| 17b32994f6 | |||
| bcc0ef8666 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
OpenAPI styling tweaks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Prevent form submission during IME composition
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Support code themes customization
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add sidesheet component, use it for TOC and AIChat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Set <html lang> based on translated language
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support social links
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix signed JWT token usage
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix OpenAPI example not showing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Align styling of PageLinkItem and PageDocumentItem
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
---
|
||||
|
||||
Update scalar dependencies.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support `defaultExpanded` in Expandable block
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Better conditional display of page actions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Track opening of search results for records and in the assistant.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Improve OpenAPI server URL validation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Header & TOC tweaks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix ordered list item index calculation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix Scalar Try it button justification
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Standardise toggle chevrons across the app
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Grow InlineActionButton to take available width on line
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/icons": patch
|
||||
---
|
||||
|
||||
Simplify installation by using public fontawesome mirror.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/browser-types": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add support for custom cookie banner registration
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Better handle huge section groups
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix Embed options in React
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Add OpenAPI 3.1 nullable array support
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
- After making code changes, run `bun format`.
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# @gitbook/browser-types
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5f66860: Use isCookiesTrackingDisabled for cookie consent integrations
|
||||
- 2e495cb: Add Global Privacy Control (GPC) support
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7a11861: Add support for custom cookie banner registration
|
||||
- Updated dependencies [6f1db32]
|
||||
- @gitbook/icons@0.4.3
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
|
||||
@@ -85,10 +85,17 @@ export type GitBookGlobal = {
|
||||
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
|
||||
|
||||
/**
|
||||
* Whether the user has granted cookie consent.
|
||||
* Returns true if cookies are granted, false if rejected or unknown.
|
||||
* Indicates whether cookies tracking is disabled for the current user.
|
||||
* Returns `true` when tracking is disabled, `false` when tracking is enabled,
|
||||
* and `undefined` when the tracking preference is unknown or not yet determined.
|
||||
*/
|
||||
hasApprovedCookies: () => boolean;
|
||||
isCookiesTrackingDisabled: () => boolean | undefined;
|
||||
|
||||
/**
|
||||
* Indicates whether global privacy control is enabled for the current user.
|
||||
* Returns `true` when global privacy control is enabled, `false` when it is disabled.
|
||||
*/
|
||||
isGlobalPrivacyControlEnabled: () => boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,5 +1,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-loose": "^8.5.2",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
|
||||
@@ -1,5 +1,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({
|
||||
|
||||
@@ -232,7 +232,6 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'run-ai-docs.nvidia.com',
|
||||
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
|
||||
@@ -14,11 +14,7 @@ import type { GitBookStandalone } from '@gitbook/embed';
|
||||
import { expect } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
VISITOR_TOKEN_COOKIE,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
} from '@/lib/visitors';
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
@@ -34,6 +30,7 @@ import {
|
||||
getCustomizationURL,
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
@@ -74,6 +71,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
await searchInput.focus();
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
@@ -99,6 +97,7 @@ const searchTestCases: Test[] = [
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+K');
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
},
|
||||
@@ -111,6 +110,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||||
},
|
||||
},
|
||||
@@ -122,6 +122,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
@@ -135,6 +136,7 @@ const searchTestCases: Test[] = [
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
@@ -147,7 +149,9 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - e2e - all pages', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -67,7 +67,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - gb-docs - all pages', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -109,7 +109,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - e2e - single-page', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
@@ -151,7 +151,7 @@ test.describe('PDF export', () => {
|
||||
const printBtn = page.getByTestId('print-button');
|
||||
await expect(printBtn).toBeVisible();
|
||||
|
||||
await argosScreenshot(page, 'pdf - all pages', {
|
||||
await argosScreenshot(page, 'pdf - gb-docs - single-page', {
|
||||
viewports: ['macbook-13'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
|
||||
@@ -155,9 +155,19 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page) {
|
||||
export async function setTimeToMorning(page: Page) {
|
||||
const now = new Date();
|
||||
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
|
||||
|
||||
await page.clock.install({ time: now });
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page, options?: { darkMode?: boolean }) {
|
||||
const selector = options?.darkMode
|
||||
? 'img[alt="Page cover"].dark\\:inline'
|
||||
: 'img[alt="Page cover"]:not(.dark\\:inline)';
|
||||
// Wait for cover images to exist (not the shimmer placeholder)
|
||||
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
|
||||
await expect(page.locator(selector)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
@@ -25,6 +26,7 @@ const nextConfig = {
|
||||
// GitBook envs
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
|
||||
@@ -6,12 +6,13 @@ import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-ca
|
||||
const originalTagCache = doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
regionalCacheTtlSec: 60 * 5 /* 5 minutes */,
|
||||
// We can set a long TTL for the regional cache, as we invalidate it on update
|
||||
regionalCacheTtlSec: 24 * 60 * 60, // 1 day
|
||||
// Because we invalidate the Cache API on update, we can safely set this to true
|
||||
regionalCacheDangerouslyPersistMissingTags: true,
|
||||
shardReplication: {
|
||||
numberOfSoftReplicas: 2,
|
||||
numberOfHardReplicas: 1,
|
||||
numberOfHardReplicas: 2,
|
||||
regionalReplication: {
|
||||
defaultRegion: 'enam',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.21.3",
|
||||
"version": "0.22.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"
|
||||
},
|
||||
|
||||
+28
-8
@@ -18,6 +18,14 @@ export async function GET(
|
||||
<html>
|
||||
<head>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<style type="text/css">
|
||||
.control {
|
||||
display: none;
|
||||
}
|
||||
.custom-trigger .control {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -105,17 +113,29 @@ export async function GET(
|
||||
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
<button type="button" class="control" onclick="window.GitBook('open')">Open</button>
|
||||
<button type="button" class="control" onclick="window.GitBook('close')">Close</button>
|
||||
</body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
<script>
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
});
|
||||
window.GitBook('open');
|
||||
const useCustomTrigger = new URLSearchParams(window.location.search).get('trigger') === 'custom';
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
tabs: ['assistant', 'docs'],
|
||||
closeButton: useCustomTrigger
|
||||
});
|
||||
|
||||
if (useCustomTrigger) {
|
||||
document.body.classList.add('custom-trigger');
|
||||
window.GitBook('hide');
|
||||
} else {
|
||||
window.GitBook('open');
|
||||
}
|
||||
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
|
||||
+14
-2
@@ -24,10 +24,22 @@ export async function GET(
|
||||
(function () {
|
||||
const w = window;
|
||||
const gb = w.GitBook;
|
||||
|
||||
function getScriptSearchParams() {
|
||||
const script = document.currentScript;
|
||||
if (!script) return new URLSearchParams();
|
||||
|
||||
const url = new URL(script.src);
|
||||
return url.searchParams;
|
||||
}
|
||||
|
||||
const searchParams = getScriptSearchParams()
|
||||
const token = searchParams.get('jwt_token');
|
||||
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)};
|
||||
const initFrameOptions = token ? { visitor: { token } } : undefined;
|
||||
|
||||
if (typeof gb === "function") {
|
||||
gb('init', initOptions);
|
||||
gb('init', initOptions, initFrameOptions);
|
||||
} else {
|
||||
var d = document;
|
||||
|
||||
@@ -40,7 +52,7 @@ export async function GET(
|
||||
};
|
||||
w.GitBook = g;
|
||||
|
||||
g('init', initOptions);
|
||||
g('init', initOptions, initFrameOptions);
|
||||
|
||||
const load = function () {
|
||||
const style = document.createElement('link');
|
||||
|
||||
+16
-3
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user