mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Merge branch 'main' into taran/resolve-page-meta-links-cr-or-space
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/embed": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve Docs Embed with separate Assistant and Docs tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix hidden section not found
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gitbook",
|
||||
@@ -346,7 +345,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.151.0",
|
||||
"@gitbook/api": "0.153.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -727,7 +726,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.151.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-5d9+rZ2u6CKKIiVHO1Toyk+7wHtTOXmP0+sVIE3teRkceX4z5FGIIpa4XsFeKC9XeosncvehdshPaOGQtSDpTQ=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.153.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ArNPFoqwId4Flicz8xPEdGqXQkzxyxP7S8Uv3wIfCX2e4cLhpcS7xCJGEHOJrqe1tNs1ovT1N2MWfpdIqzXqig=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.151.0",
|
||||
"@gitbook/api": "0.153.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
+370
-8
@@ -1,24 +1,61 @@
|
||||
# `@gitbook/embed`
|
||||
# GitBook Docs Embed (`@gitbook/embed`)
|
||||
|
||||
Embed the GitBook Docs Assistant in your product or website.
|
||||
Embed your GitBook docs in your product or website.
|
||||
|
||||
The Docs Embed can contain two tabs:
|
||||
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
|
||||
- **Docs**: A browser for navigating your documentation site
|
||||
|
||||
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
|
||||
|
||||
# Usage
|
||||
|
||||
## As a script from your docs site
|
||||
## As a standalone script from your docs site
|
||||
|
||||
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
||||
All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website.
|
||||
|
||||
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||
|
||||
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
||||
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
|
||||
|
||||
```html
|
||||
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||
<script>
|
||||
// Initialize with Authenticated Access (optional)
|
||||
window.GitBook('init',
|
||||
{ siteURL: 'https://docs.company.com' },
|
||||
{ visitor: { token: 'your-jwt-token' } }
|
||||
);
|
||||
window.GitBook('show');
|
||||
</script>
|
||||
```
|
||||
|
||||
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
|
||||
|
||||
### Example: Configuring the widget
|
||||
|
||||
```javascript
|
||||
GitBook('configure', {
|
||||
button: {
|
||||
label: 'Ask',
|
||||
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
||||
},
|
||||
tabs: ['assistant', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
label: 'Contact Support',
|
||||
onClick: () => window.open('https://support.example.com', '_blank')
|
||||
}
|
||||
],
|
||||
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||
tools: [/* ... */]
|
||||
});
|
||||
```
|
||||
|
||||
See the [Configuration](#configuration) section for all available options.
|
||||
|
||||
## As a package from NPM
|
||||
|
||||
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||
@@ -30,10 +67,46 @@ const gitbook = createGitBook({
|
||||
siteURL: 'https://docs.company.com'
|
||||
});
|
||||
|
||||
// Create an iframe and get its URL
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = gitbook.getFrameURL();
|
||||
iframe.src = gitbook.getFrameURL({
|
||||
visitor: {
|
||||
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||
unsignedClaims: { // Optional: custom claims for dynamic expressions
|
||||
userId: '123',
|
||||
plan: 'premium'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create a frame client to communicate with the iframe
|
||||
const frame = gitbook.createFrame(iframe);
|
||||
|
||||
// Use the frame client methods
|
||||
frame.navigateToPage('/getting-started'); // Navigate to a page in the docs tab
|
||||
frame.navigateToAssistant(); // Switch to the assistant tab
|
||||
frame.postUserMessage('How do I get started?');
|
||||
frame.clearChat();
|
||||
|
||||
// Configure the embed (see Configuration section for all options)
|
||||
frame.configure({
|
||||
tabs: ['assistant', 'docs'],
|
||||
actions: [
|
||||
{
|
||||
icon: 'circle-question',
|
||||
label: 'Contact Support',
|
||||
onClick: () => window.open('https://support.example.com', '_blank')
|
||||
}
|
||||
],
|
||||
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||
tools: [/* ... */]
|
||||
});
|
||||
|
||||
// Listen to events
|
||||
frame.on('close', () => {
|
||||
console.log('Frame closed');
|
||||
});
|
||||
```
|
||||
|
||||
## As React components
|
||||
@@ -41,9 +114,298 @@ const frame = gitbook.createFrame(iframe);
|
||||
After installing the NPM package, you can import prebuilt React components:
|
||||
|
||||
```tsx
|
||||
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||
import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||
|
||||
<GitBookProvider siteURL="https://docs.company.com">
|
||||
<GitBookAssistantFrame />
|
||||
<GitBookFrame
|
||||
visitor={{
|
||||
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
|
||||
}}
|
||||
tabs={['assistant', 'docs']}
|
||||
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||
suggestions={['What is GitBook?', 'How do I get started?']}
|
||||
actions={[
|
||||
{
|
||||
icon: 'circle-question',
|
||||
label: 'Contact Support',
|
||||
onClick: () => window.open('https://support.example.com', '_blank')
|
||||
}
|
||||
]}
|
||||
tools={[/* ... */]}
|
||||
/>
|
||||
</GitBookProvider>
|
||||
```
|
||||
|
||||
You can also use the `useGitBook` hook to access the client:
|
||||
|
||||
```tsx
|
||||
import { useGitBook } from '@gitbook/embed/react';
|
||||
|
||||
function MyComponent() {
|
||||
const gitbook = useGitBook();
|
||||
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
# API Reference
|
||||
|
||||
## Method Comparison
|
||||
|
||||
| Method | Standalone Script | NPM Package | React Components |
|
||||
|--------|------------------|-------------|------------------|
|
||||
| **Initialize** | `GitBook('init', options, frameOptions)` | `createGitBook(options)` | `<GitBookProvider siteURL="...">` |
|
||||
| **Get frame URL** | ❌ (handled internally) | `client.getFrameURL(options)` | `useGitBook().getFrameURL(options)` |
|
||||
| **Create frame client** | ❌ (handled internally) | `client.createFrame(iframe)` | `useGitBook().createFrame(iframe)` |
|
||||
| **Show/Hide widget** | `GitBook('show')` / `GitBook('hide')` | ❌ | ❌ |
|
||||
| **Open/Close window** | `GitBook('open')` / `GitBook('close')` / `GitBook('toggle')` | ❌ | ❌ |
|
||||
| **Navigate to page** | `GitBook('navigateToPage', path)` | `frame.navigateToPage(path)` | Via frame client |
|
||||
| **Navigate to assistant** | `GitBook('navigateToAssistant')` | `frame.navigateToAssistant()` | Via frame client |
|
||||
| **Post message** | `GitBook('postUserMessage', message)` | `frame.postUserMessage(message)` | Via frame client |
|
||||
| **Clear chat** | `GitBook('clearChat')` | `frame.clearChat()` | Via frame client |
|
||||
| **Configure** | `GitBook('configure', settings)` | `frame.configure(settings)` | Props on `<GitBookFrame>` |
|
||||
| **Event listeners** | ❌ | `frame.on(event, listener)` | Via frame client |
|
||||
| **Unload** | `GitBook('unload')` | ❌ | ❌ |
|
||||
|
||||
## Method Signatures
|
||||
|
||||
### Standalone Script
|
||||
|
||||
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
|
||||
- `GitBook('show')` - Show widget button
|
||||
- `GitBook('hide')` - Hide widget button
|
||||
- `GitBook('open')` - Open widget window
|
||||
- `GitBook('close')` - Close widget window
|
||||
- `GitBook('toggle')` - Toggle widget window
|
||||
- `GitBook('navigateToPage', path: string)` - Navigate to page
|
||||
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
|
||||
- `GitBook('postUserMessage', message: string)` - Post message to chat
|
||||
- `GitBook('clearChat')` - Clear chat history
|
||||
- `GitBook('configure', settings: {...})` - Configure widget
|
||||
- `GitBook('unload')` - Unload widget
|
||||
|
||||
### NPM Package
|
||||
|
||||
**Client Factory:**
|
||||
- `createGitBook(options: { siteURL: string })` → `GitBookClient`
|
||||
- `client.getFrameURL(options?: { visitor?: {...} })` → `string`
|
||||
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
|
||||
|
||||
**Frame Client:**
|
||||
- `frame.navigateToPage(path: string)` → `void`
|
||||
- `frame.navigateToAssistant()` → `void`
|
||||
- `frame.postUserMessage(message: string)` → `void`
|
||||
- `frame.clearChat()` → `void`
|
||||
- `frame.configure(settings: Partial<GitBookEmbeddableConfiguration>)` → `void`
|
||||
- `frame.on(event: string, listener: Function)` → `() => void` (unsubscribe)
|
||||
|
||||
### React Components
|
||||
|
||||
**Components:**
|
||||
- `<GitBookProvider siteURL: string>` - Provider component
|
||||
- `<GitBookFrame {...props}>` - Frame component (accepts all config options as props)
|
||||
|
||||
**Hooks:**
|
||||
- `useGitBook()` → `GitBookClient` (must be used within `<GitBookProvider>`)
|
||||
|
||||
# Configuration
|
||||
|
||||
Configuration options are available across usage methods as follows:
|
||||
- **Standalone script**: via `GitBook('configure', {...})`
|
||||
- **NPM package**: via `frame.configure({...})`
|
||||
- **React components**: via props on `<GitBookFrame>`
|
||||
|
||||
### `tabs`
|
||||
|
||||
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']
|
||||
```
|
||||
|
||||
### `actions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Custom action buttons rendered in the sidebar alongside tabs. Each action button triggers a callback when clicked.
|
||||
|
||||
**Note**: This prop was previously named `buttons`. Use `actions` instead, it has the same functionality.
|
||||
|
||||
- **Type**: `GitBookEmbeddableActionDefinition[]`
|
||||
- **Properties**:
|
||||
- `icon`: `string` - Icon name. Any [FontAwesome icon](https://fontawesome.com/search) is supported. (e.g., `'rocket'`, `'comments'`, `'user-circle'`, ...)
|
||||
- `label`: `string` - Button label text
|
||||
- `onClick`: `() => void | Promise<void>` - Callback function when clicked
|
||||
|
||||
```javascript
|
||||
actions: [
|
||||
{
|
||||
icon: 'comments',
|
||||
label: 'Contact Support',
|
||||
onClick: () => window.open('https://support.example.com', '_blank')
|
||||
},
|
||||
{
|
||||
icon: 'rocket',
|
||||
label: 'Get started',
|
||||
onClick: () => {
|
||||
GitBook('navigateToPage', '/getting-started');
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `greeting`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Welcome message displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) tab.
|
||||
|
||||
- **Type**: `{ title: string, subtitle: string }`
|
||||
|
||||
```javascript
|
||||
greeting: {
|
||||
title: 'Welcome!',
|
||||
subtitle: 'How can I help you today?'
|
||||
}
|
||||
```
|
||||
|
||||
### `suggestions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Suggested questions displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) welcome screen.
|
||||
|
||||
- **Type**: `string[]`
|
||||
|
||||
```javascript
|
||||
suggestions: [
|
||||
'What is GitBook?',
|
||||
'How do I get started?',
|
||||
'What can you do?'
|
||||
]
|
||||
```
|
||||
|
||||
### `tools`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Custom AI tools to extend the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant). Tools allow the assistant to execute functions and integrate with your own systems.
|
||||
|
||||
**Note**: In addition to custom tools, the Assistant will always have access to any [MCP servers you define](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant#extend-gitbook-assistant-with-mcp-servers) in your site's AI settings.
|
||||
|
||||
- **Type**: `GitBookToolDefinition[]`
|
||||
- **Properties**:
|
||||
- `name`: `string` - Unique tool identifier
|
||||
- `description`: `string` - Description of what the tool does (used by the AI to decide when and how to use it).
|
||||
- `inputSchema`: `object` - JSON schema defining the tool's input parameters
|
||||
- `execute`: `(input: object) => Promise<{ output: any, summary: string }>` - Async function that executes the tool.
|
||||
- `output`: The result of the tool execution, provided to the AI to continue working with. Not shown to the user.
|
||||
- `summary`: The visual summary of the tool execution, shown in the user's chat window.
|
||||
- `confirmation`: `{ icon?: string, label: string }` (optional) - Confirmation button shown before execution, useful for actions that require the user's express approval.
|
||||
|
||||
```javascript
|
||||
tools: [
|
||||
{
|
||||
name: 'get_user_info',
|
||||
description: 'Get information about the current user',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: {
|
||||
type: 'string',
|
||||
description: 'The user ID to look up'
|
||||
}
|
||||
},
|
||||
required: ['userId']
|
||||
},
|
||||
execute: async (input) => {
|
||||
const user = await fetch(`/api/users/${input.userId}`).then(r => r.json());
|
||||
return {
|
||||
output: { name: user.name, plan: user.plan },
|
||||
summary: `Retrieved info for user ${user.name}`
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'create_ticket',
|
||||
description: 'Create a support ticket',
|
||||
confirmation: {
|
||||
icon: 'circle-question',
|
||||
label: 'Create support ticket?'
|
||||
},
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subject: { type: 'string' },
|
||||
description: { type: 'string' }
|
||||
},
|
||||
required: ['subject', 'description']
|
||||
},
|
||||
execute: async (input) => {
|
||||
const ticket = await fetch('/api/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}).then(r => r.json());
|
||||
return {
|
||||
output: { ticketId: ticket.id },
|
||||
summary: `Created ticket #${ticket.id}`
|
||||
};
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `visitor` (Authenticated Access)
|
||||
|
||||
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
|
||||
|
||||
[Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access) options passed when creating the frame URL. Used for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) and [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access).
|
||||
|
||||
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
|
||||
|
||||
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
|
||||
**NPM package**: Pass to `getFrameURL({ visitor: {...} })`
|
||||
**React components**: Pass as the `visitor` prop on `<GitBookFrame>`
|
||||
|
||||
- **Type**: `{ token?: string, unsignedClaims?: Record<string, unknown> }`
|
||||
- **Properties**:
|
||||
- `token`: `string` (optional) - Signed JWT token for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) or [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access)
|
||||
- `unsignedClaims`: `Record<string, unknown>` (optional) - Unsigned claims that can be used in dynamic expressions via `visitor.claims.unsigned.<claim-name>`
|
||||
|
||||
```javascript
|
||||
visitor: {
|
||||
token: 'your-jwt-token',
|
||||
unsignedClaims: {
|
||||
userId: '123',
|
||||
plan: 'premium',
|
||||
role: 'admin'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `button`
|
||||
|
||||
Available in: Standalone script only
|
||||
|
||||
Configure the widget button for the standalone script. This option is not available when using the NPM package or React components, since they can be customized completely.
|
||||
|
||||
- **Type**: `{ label: string, icon: 'assistant' | 'sparkle' | 'help' | 'book' }`
|
||||
- **Properties**:
|
||||
- `label`: `string` - Button label text
|
||||
- `icon`: `'assistant' | 'sparkle' | 'help' | 'book'` - Icon displayed on the button. Choose from one of 4 presets.
|
||||
|
||||
```javascript
|
||||
button: {
|
||||
label: 'Ask',
|
||||
icon: 'assistant'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ export function createGitBook(options: CreateGitBookOptions) {
|
||||
const client: GitBookClient = {
|
||||
getFrameURL: (frameOptions) => {
|
||||
const url = new URL(options.siteURL);
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
|
||||
|
||||
if (frameOptions.visitor?.token) {
|
||||
url.searchParams.set('token', frameOptions.visitor.token);
|
||||
|
||||
@@ -64,8 +64,9 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
buttons: [],
|
||||
welcomeMessage: '',
|
||||
tabs: ['assistant', 'docs'],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export type GitBookToolDefinition = AIToolDefinition & {
|
||||
/**
|
||||
* Custom button definition to be passed to the embeddable GitBook.
|
||||
*/
|
||||
export type GitBookEmbeddableButtonDefinition = {
|
||||
export type GitBookEmbeddableActionDefinition = {
|
||||
/**
|
||||
* Icon to be displayed in the button.
|
||||
*/
|
||||
@@ -41,16 +41,26 @@ export type GitBookEmbeddableButtonDefinition = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Overall configuration for the layout of the embeddable GitBook.
|
||||
* Overall configuration for the layout of the GitBook embed.
|
||||
*/
|
||||
export type GitBookEmbeddableConfiguration = {
|
||||
/** Tabs to display in the embed (if enabled on the site). */
|
||||
tabs: ('assistant' | 'docs')[];
|
||||
|
||||
/** Additional buttons to be displayed in the header of the GitBook embed. */
|
||||
actions: GitBookEmbeddableActionDefinition[];
|
||||
|
||||
/**
|
||||
* Buttons to be displayed in the header of the embeddable GitBook.
|
||||
* Additional buttons to be displayed in the header of the GitBook embed.
|
||||
* @deprecated Use `actions` instead.
|
||||
*/
|
||||
buttons: GitBookEmbeddableButtonDefinition[];
|
||||
buttons?: GitBookEmbeddableActionDefinition[];
|
||||
|
||||
/** Message to be displayed in the welcome page. */
|
||||
welcomeMessage: string;
|
||||
greeting: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
/** Suggestions of questions to be displayed in the welcome page. */
|
||||
suggestions: string[];
|
||||
|
||||
@@ -17,7 +17,7 @@ export type GitBookFrameProps = {
|
||||
* Render a frame with the GitBook Assistant in it.
|
||||
*/
|
||||
export function GitBookFrame(props: GitBookFrameProps) {
|
||||
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||
const { className, visitor, actions, greeting, suggestions, tools } = props;
|
||||
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const gitbook = useGitBook();
|
||||
@@ -33,12 +33,13 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
|
||||
useEffect(() => {
|
||||
gitbookFrame?.configure({
|
||||
buttons,
|
||||
welcomeMessage,
|
||||
tabs: ['assistant', 'docs'],
|
||||
actions,
|
||||
greeting,
|
||||
suggestions,
|
||||
tools,
|
||||
});
|
||||
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
|
||||
}, [gitbookFrame, actions, greeting, suggestions, tools]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
|
||||
@@ -31,24 +31,50 @@ type StandaloneCalls =
|
||||
// Clear the chat
|
||||
| ['clearChat']
|
||||
// Configure the embed
|
||||
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||
| ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>]
|
||||
// Navigate to a page
|
||||
| ['navigateToPage', string]
|
||||
// Navigate to the assistant
|
||||
| ['navigateToAssistant'];
|
||||
|
||||
type StandaloneConfiguration = {
|
||||
/** Configure the button to open the embed */
|
||||
button: {
|
||||
/** Label to be displayed in the button. */
|
||||
label: string;
|
||||
/** Icon to be displayed in the button. */
|
||||
icon: 'assistant' | 'sparkle' | 'help' | 'book';
|
||||
};
|
||||
};
|
||||
|
||||
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||
q?: StandaloneCalls[];
|
||||
};
|
||||
|
||||
let widgetIframe: HTMLIFrameElement | undefined;
|
||||
let _client: GitBookClient | undefined;
|
||||
let _frame: GitBookFrameClient | undefined;
|
||||
let frameOptions: GetFrameURLOptions | undefined;
|
||||
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
|
||||
button: {
|
||||
label: 'Ask',
|
||||
icon: 'assistant',
|
||||
},
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
};
|
||||
|
||||
const widgetButton = document.createElement('button');
|
||||
widgetButton.id = 'gitbook-widget-button';
|
||||
widgetButton.addEventListener('click', () => {
|
||||
GitBook('toggle');
|
||||
});
|
||||
widgetButton.innerHTML = `
|
||||
<span id="gitbook-widget-button-icon"></span>
|
||||
<span id="gitbook-widget-button-label">Ask</span>
|
||||
<span id="gitbook-widget-button-icon" data-icon="${frameConfiguration.button.icon}"></span>
|
||||
<span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span>
|
||||
`;
|
||||
|
||||
const widgetWindow = document.createElement('div');
|
||||
@@ -58,17 +84,6 @@ widgetWindow.classList.add('hidden');
|
||||
document.body.appendChild(widgetButton);
|
||||
document.body.appendChild(widgetWindow);
|
||||
|
||||
let widgetIframe: HTMLIFrameElement | undefined;
|
||||
let _client: GitBookClient | undefined;
|
||||
let _frame: GitBookFrameClient | undefined;
|
||||
let frameOptions: GetFrameURLOptions | undefined;
|
||||
let frameConfiguration: GitBookEmbeddableConfiguration = {
|
||||
buttons: [],
|
||||
welcomeMessage: '',
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
function getClient() {
|
||||
if (!_client) {
|
||||
throw new Error(
|
||||
@@ -135,27 +150,31 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
case 'postUserMessage':
|
||||
getIframe().frame.postUserMessage(args[1]);
|
||||
break;
|
||||
case 'configure':
|
||||
case 'configure': {
|
||||
const settings = args[1];
|
||||
frameConfiguration = {
|
||||
...frameConfiguration,
|
||||
...args[1],
|
||||
...settings,
|
||||
};
|
||||
// Update the button label and icon
|
||||
if (settings.button?.label) {
|
||||
const label = widgetButton.querySelector('#gitbook-widget-button-label');
|
||||
if (label) {
|
||||
label.textContent = settings.button.label;
|
||||
}
|
||||
}
|
||||
if (settings.button?.icon) {
|
||||
const icon = widgetButton.querySelector('#gitbook-widget-button-icon');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-icon', settings.button.icon);
|
||||
}
|
||||
}
|
||||
|
||||
getIframe().frame.configure({
|
||||
...frameConfiguration,
|
||||
buttons: [
|
||||
...frameConfiguration.buttons,
|
||||
|
||||
// Always include a close button
|
||||
{
|
||||
icon: 'close',
|
||||
label: 'Close',
|
||||
onClick: () => {
|
||||
GitBook('close');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'clearChat':
|
||||
getIframe().frame.clearChat();
|
||||
break;
|
||||
|
||||
@@ -113,6 +113,18 @@
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
#gitbook-widget-button-icon[data-icon="sparkle"] {
|
||||
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/sparkle.svg?v=2&token=a463935e93");
|
||||
}
|
||||
|
||||
#gitbook-widget-button-icon[data-icon="help"] {
|
||||
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/circle-question.svg?v=2&token=a463935e93");
|
||||
}
|
||||
|
||||
#gitbook-widget-button-icon[data-icon="book"] {
|
||||
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/book-open.svg?v=2&token=a463935e93");
|
||||
}
|
||||
|
||||
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||
}
|
||||
|
||||
+23
-2
@@ -1,7 +1,28 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default async function Page() {
|
||||
return <EmbeddableAssistantPage />;
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
|
||||
// If the assistant is not enabled, redirect to the docs
|
||||
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
|
||||
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbeddableAssistantPage
|
||||
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||
siteTitle={context.site.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
|
||||
|
||||
// If assistant is enabled, redirect to assistant, otherwise to docs
|
||||
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
|
||||
redirect(`${baseURL}/assistant`);
|
||||
} else {
|
||||
redirect(`${baseURL}/page/`);
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -1,7 +1,28 @@
|
||||
import type { RouteParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default async function Page() {
|
||||
return <EmbeddableAssistantPage />;
|
||||
type PageProps = {
|
||||
params: Promise<RouteParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
|
||||
// If the assistant is not enabled, redirect to the docs
|
||||
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
|
||||
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbeddableAssistantPage
|
||||
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||
siteTitle={context.site.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableStaticContext(params);
|
||||
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
|
||||
|
||||
// If assistant is enabled, redirect to assistant, otherwise to docs
|
||||
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
|
||||
redirect(`${baseURL}/assistant`);
|
||||
} else {
|
||||
redirect(`${baseURL}/page/`);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export function useAI(): AIContext {
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
className="size-4"
|
||||
/>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
|
||||
@@ -87,6 +87,19 @@ export type AIChatState = {
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
export type AIChatEvent =
|
||||
| { type: 'open' }
|
||||
| { type: 'postMessage'; message: string }
|
||||
| { type: 'clear' }
|
||||
| { type: 'close' };
|
||||
|
||||
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||
Extract<AIChatEvent, { type: T }>,
|
||||
'type'
|
||||
>;
|
||||
|
||||
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
|
||||
|
||||
export type AIChatController = {
|
||||
/** Open the dialog */
|
||||
open: () => void;
|
||||
@@ -96,6 +109,11 @@ export type AIChatController = {
|
||||
postMessage: (input: { message: string }) => void;
|
||||
/** Clear the conversation */
|
||||
clear: () => void;
|
||||
/** Register an event listener */
|
||||
on: <T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
listener: (input?: AIChatEventData<T>) => void
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||
@@ -123,6 +141,17 @@ export function useAIChatState(): AIChatState {
|
||||
return state;
|
||||
}
|
||||
|
||||
function notify(
|
||||
listeners: AIChatEventListener[] | undefined,
|
||||
input: Omit<AIChatEvent, 'type'>
|
||||
): void {
|
||||
if (!listeners) return;
|
||||
// Defer event listeners to next tick so React can process state updates first
|
||||
setTimeout(() => {
|
||||
listeners.forEach((listener) => listener(input));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the controller to interact with the AI chat.
|
||||
*/
|
||||
@@ -137,6 +166,9 @@ export function AIChatProvider(props: {
|
||||
const [, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
// Open AI chat and sync with search state
|
||||
const onOpen = React.useCallback(() => {
|
||||
const { initialQuery } = globalState.getState();
|
||||
@@ -149,6 +181,8 @@ export function AIChatProvider(props: {
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('open'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Close AI chat and clear ask parameter
|
||||
@@ -162,6 +196,8 @@ export function AIChatProvider(props: {
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('close'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
@@ -379,8 +415,14 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}
|
||||
|
||||
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||
|
||||
if (query === input.message) {
|
||||
// Return early if the message is the same as the previous message
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
opened: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -440,14 +482,34 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
listener: (input?: AIChatEventData<T>) => void
|
||||
) => {
|
||||
const listeners = eventsRef.current.get(event) || [];
|
||||
listeners.push(listener as AIChatEventListener);
|
||||
eventsRef.current.set(event, listeners);
|
||||
return () => {
|
||||
const currentListeners = eventsRef.current.get(event) || [];
|
||||
eventsRef.current.set(
|
||||
event,
|
||||
currentListeners.filter((l) => l !== listener)
|
||||
);
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const controller = React.useMemo(() => {
|
||||
return {
|
||||
open: onOpen,
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
on: onEvent,
|
||||
};
|
||||
}, [onOpen, onClose, onClear, onPostMessage]);
|
||||
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSubtitle,
|
||||
EmbeddableFrameTitle,
|
||||
} from '../Embeddable/EmbeddableFrame';
|
||||
@@ -78,29 +79,31 @@ export function AIChat() {
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
icon="close"
|
||||
label={tString(language, 'close')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<AIChatBody chatController={chatController} chat={chat} />
|
||||
</EmbeddableFrameBody>
|
||||
<EmbeddableFrameMain>
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
icon="close"
|
||||
label={tString(language, 'close')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<AIChatBody chatController={chatController} chat={chat} />
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
</EmbeddableFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -111,13 +114,14 @@ export function AIChat() {
|
||||
*/
|
||||
export function AIChatDynamicIcon(props: {
|
||||
trademark: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { trademark } = props;
|
||||
const { trademark, className } = props;
|
||||
const chat = useAIChatState();
|
||||
|
||||
return (
|
||||
<AIChatIcon
|
||||
className="size-5 text-tint"
|
||||
className={tcls('size-5 text-tint', className)}
|
||||
trademark={trademark}
|
||||
state={
|
||||
chat.error
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { t } from '@/intl/translate';
|
||||
import { useAIChatController, useAIChatState } from '../AI';
|
||||
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
/**
|
||||
* Button to control the chat (clear, etc.)
|
||||
@@ -15,26 +14,16 @@ export function AIChatControlButton() {
|
||||
const chatController = useAIChatController();
|
||||
|
||||
return chat.messages.length > 0 ? (
|
||||
<DropdownMenu
|
||||
button={
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
iconOnly
|
||||
icon="ellipsis"
|
||||
label={tString(language, 'actions')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
chatController.clear();
|
||||
}}
|
||||
>
|
||||
<Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
|
||||
{t(language, 'ai_chat_clear_conversation')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
onClick={() => {
|
||||
chatController.clear();
|
||||
}}
|
||||
iconOnly
|
||||
icon="trash-can"
|
||||
label={t(language, 'ai_chat_clear_conversation')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
className="animate-blur-in-slow"
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function AIChatInput(props: {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
||||
<HoverCardRoot>
|
||||
<HoverCardRoot openDelay={500}>
|
||||
<HoverCard
|
||||
className="max-w-xs bg-tint p-2 text-sm text-tint"
|
||||
arrow={{ className: 'fill-tint-3' }}
|
||||
|
||||
@@ -32,10 +32,9 @@ export function Hint({
|
||||
className={tcls(
|
||||
'hint',
|
||||
'transition-colors',
|
||||
'rounded-md',
|
||||
hasHeading ? 'rounded-l-sm' : null,
|
||||
'straight-corners:rounded-none',
|
||||
'rounded-corners:rounded-md',
|
||||
'circular-corners:rounded-xl',
|
||||
hasHeading ? 'circular-corners:rounded-l-none rounded-corners:rounded-l-none' : '',
|
||||
'overflow-hidden',
|
||||
hasHeading ? ['border-l-2', hintStyle.containerWithHeader] : hintStyle.container,
|
||||
|
||||
|
||||
@@ -36,13 +36,13 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
|
||||
<div className="relative select-none">
|
||||
<div
|
||||
className={tcls(
|
||||
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-original theme-muted:bg-primary-subtle tabular-nums contrast-more:bg-primary-solid',
|
||||
'font-medium text-contrast-primary-original theme-muted:text-primary contrast-more:text-contrast-primary-solid'
|
||||
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-solid theme-muted:bg-tint-base tabular-nums contrast-more:bg-primary-11',
|
||||
'font-medium text-contrast-primary-solid theme-muted:text-tint contrast-more:text-contrast-primary-11'
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="can-override-bg absolute top-9 bottom-2 left-3.5 w-px bg-primary-7 theme-muted:bg-primary-subtle" />
|
||||
<div className="can-override-bg absolute top-9 bottom-2 left-3.5 w-px bg-primary-7 theme-muted:bg-tint-6" />
|
||||
</div>
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
|
||||
@@ -3,17 +3,17 @@ import type { DocumentMarkColor } from '@gitbook/api';
|
||||
|
||||
export const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = {
|
||||
default: [],
|
||||
blue: ['text-blue-500'],
|
||||
red: ['text-red-500'],
|
||||
green: ['text-green-500'],
|
||||
yellow: ['text-yellow-600'],
|
||||
purple: ['text-purple-500'],
|
||||
orange: ['text-orange-500'],
|
||||
$primary: ['text-primary'],
|
||||
$info: ['text-info'],
|
||||
$success: ['text-success'],
|
||||
$warning: ['text-warning'],
|
||||
$danger: ['text-danger'],
|
||||
blue: ['text-blue-500 contrast-more:text-blue-800'],
|
||||
red: ['text-red-500 contrast-more:text-red-800'],
|
||||
green: ['text-green-500 contrast-more:text-green-800'],
|
||||
yellow: ['text-yellow-600 contrast-more:text-yellow-800'],
|
||||
purple: ['text-purple-500 contrast-more:text-purple-800'],
|
||||
orange: ['text-orange-500 contrast-more:text-orange-800'],
|
||||
$primary: ['text-primary-subtle contrast-more:text-primary'],
|
||||
$info: ['text-info-subtle contrast-more:text-info'],
|
||||
$success: ['text-success-subtle contrast-more:text-success'],
|
||||
$warning: ['text-warning-subtle contrast-more:text-warning'],
|
||||
$danger: ['text-danger-subtle contrast-more:text-danger'],
|
||||
};
|
||||
|
||||
export const backgroundColorToStyle: {
|
||||
|
||||
@@ -18,20 +18,36 @@ import {
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSidebar,
|
||||
EmbeddableFrameTitle,
|
||||
} from './EmbeddableFrame';
|
||||
import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI';
|
||||
import {
|
||||
EmbeddableIframeButtons,
|
||||
EmbeddableIframeTabs,
|
||||
useEmbeddableConfiguration,
|
||||
} from './EmbeddableIframeAPI';
|
||||
|
||||
type EmbeddableAIChatProps = {
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Embeddable AI chat window in an iframe.
|
||||
*/
|
||||
export function EmbeddableAIChat() {
|
||||
export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
const { baseURL, siteTitle } = props;
|
||||
const chat = useAIChatState();
|
||||
const { config } = useAI();
|
||||
const chatController = useAIChatController();
|
||||
const configuration = useEmbeddableConfiguration();
|
||||
const language = useLanguage();
|
||||
|
||||
React.useEffect(() => {
|
||||
chatController.open();
|
||||
}, [chatController]);
|
||||
|
||||
// Track the view of the AI chat
|
||||
const trackEvent = useTrackEvent();
|
||||
React.useEffect(() => {
|
||||
@@ -46,28 +62,45 @@ export function EmbeddableAIChat() {
|
||||
);
|
||||
}, [trackEvent]);
|
||||
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<EmbeddableFrame>
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
<EmbeddableIframeButtons />
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<AIChatBody
|
||||
chatController={chatController}
|
||||
chat={chat}
|
||||
suggestions={configuration.suggestions}
|
||||
<EmbeddableFrameSidebar>
|
||||
<EmbeddableIframeTabs
|
||||
ref={tabsRef}
|
||||
active="assistant"
|
||||
baseURL={baseURL}
|
||||
siteTitle={siteTitle}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
<EmbeddableIframeButtons />
|
||||
</EmbeddableFrameSidebar>
|
||||
<EmbeddableFrameMain>
|
||||
<EmbeddableFrameHeader>
|
||||
{!tabsRef.current ? (
|
||||
<AIChatDynamicIcon
|
||||
className="animate-blur-in-slow"
|
||||
trademark={config.trademark}
|
||||
/>
|
||||
) : null}
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<AIChatBody
|
||||
chatController={chatController}
|
||||
chat={chat}
|
||||
suggestions={configuration.suggestions}
|
||||
/>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
</EmbeddableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { EmbeddableAIChat } from './EmbeddableAIChat';
|
||||
|
||||
type EmbeddableAssistantPageProps = {
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable page component for the embed assistant page.
|
||||
*/
|
||||
export async function EmbeddableAssistantPage() {
|
||||
return <EmbeddableAIChat />;
|
||||
export async function EmbeddableAssistantPage(props: EmbeddableAssistantPageProps) {
|
||||
return <EmbeddableAIChat baseURL={props.baseURL} siteTitle={props.siteTitle} />;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { type PagePathParams, getSitePageData } from '@/components/SitePage';
|
||||
|
||||
import { PageBody } from '@/components/PageBody';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
import type { Metadata } from 'next';
|
||||
import { Button } from '../primitives';
|
||||
import { HeaderMobileMenu } from '../Header/HeaderMobileMenu';
|
||||
import { PageBody } from '../PageBody';
|
||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||
import { TableOfContents } from '../TableOfContents';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { EmbeddableDocsPageControlButtons } from './EmbeddableDocsPageControlButtons';
|
||||
import {
|
||||
EmbeddableFrame,
|
||||
EmbeddableFrameBody,
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSidebar,
|
||||
EmbeddableFrameTitle,
|
||||
} from './EmbeddableFrame';
|
||||
import { EmbeddableIframeButtons } from './EmbeddableIframeAPI';
|
||||
import { EmbeddableIframeButtons, EmbeddableIframeTabs } from './EmbeddableIframeAPI';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
@@ -33,32 +39,58 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
|
||||
|
||||
return (
|
||||
<EmbeddableFrame className="site-background">
|
||||
<EmbeddableFrameHeader>
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<Button
|
||||
href={context.linker.toPathInSite('~gitbook/embed/assistant')}
|
||||
size="default"
|
||||
variant="blank"
|
||||
icon="arrow-left"
|
||||
label="Back"
|
||||
/>
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<EmbeddableIframeButtons />
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<PageBody
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
withPageFeedback={withPageFeedback}
|
||||
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
|
||||
/>
|
||||
<EmbeddableFrameSidebar>
|
||||
<EmbeddableIframeTabs
|
||||
active="docs"
|
||||
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||
siteTitle={context.site.title}
|
||||
/>
|
||||
<EmbeddableIframeButtons />
|
||||
</EmbeddableFrameSidebar>
|
||||
<EmbeddableFrameMain>
|
||||
<div className="relative flex not-hydrated:animate-blur-in-slow flex-col">
|
||||
<EmbeddableFrameHeader>
|
||||
<HeaderMobileMenu className="-ml-2 page-no-toc:hidden" />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>{context.site.title}</EmbeddableFrameTitle>
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<EmbeddableDocsPageControlButtons
|
||||
href={context.linker
|
||||
.toPathForPage({
|
||||
pages: context.revision.pages,
|
||||
page,
|
||||
})
|
||||
.replace(/~gitbook\/embed\/page\/?/, '')}
|
||||
/>
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
{context.sections ? (
|
||||
<SiteSectionTabs
|
||||
className="-mt-2 border-tint-subtle border-b"
|
||||
sections={encodeClientSiteSections(context, context.sections)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</EmbeddableFrameBody>
|
||||
<EmbeddableFrameBody>
|
||||
<ScrollContainer
|
||||
orientation="vertical"
|
||||
className="not-hydrated:animate-blur-in-slow"
|
||||
contentClassName="p-4"
|
||||
fadeEdges={context.sections ? [] : ['leading']}
|
||||
>
|
||||
<TableOfContents className="pt-0" context={context} />
|
||||
<PageBody
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
withPageFeedback={withPageFeedback}
|
||||
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
|
||||
/>
|
||||
</ScrollContainer>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
</EmbeddableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export function EmbeddableDocsPageControlButtons(props: { href: string }) {
|
||||
const { href } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon="arrow-up-right-from-square"
|
||||
label={tString(language, 'open_in_new_tab')}
|
||||
href={href}
|
||||
target="_blank"
|
||||
iconOnly
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
|
||||
<div
|
||||
{...divProps}
|
||||
className={tcls(
|
||||
'flex h-full grow flex-col overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint',
|
||||
'flex h-full grow overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint',
|
||||
divProps.className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -28,13 +28,19 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
|
||||
}
|
||||
);
|
||||
|
||||
export function EmbeddableFrameMain(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return <div className="flex flex-1 flex-col overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
export function EmbeddableFrameHeader(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex animate-fade-in-slow select-none items-center gap-2 px-4 py-2 text-tint-strong">
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -45,7 +51,7 @@ export function EmbeddableFrameHeaderMain(props: {
|
||||
}) {
|
||||
const { children } = props;
|
||||
|
||||
return <div className="flex flex-1 flex-col">{children}</div>;
|
||||
return <div className="flex h-8 flex-1 flex-col justify-center">{children}</div>;
|
||||
}
|
||||
|
||||
export function EmbeddableFrameBody(props: {
|
||||
@@ -82,10 +88,21 @@ export function EmbeddableFrameSubtitle(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddableFrameButtons(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export function EmbeddableFrameSidebar(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return <div className="-mr-2 ml-auto flex gap-2">{children}</div>;
|
||||
return (
|
||||
<div className="flex w-13 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">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddableFrameButtons(props: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { children, className } = props;
|
||||
|
||||
return <div className={tcls('flex gap-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
|
||||
import { createChannel } from 'bidc';
|
||||
import React from 'react';
|
||||
|
||||
import { useAIChatController } from '@/components/AI';
|
||||
import { useAI, useAIChatController } from '@/components/AI';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
|
||||
buttons: [],
|
||||
welcomeMessage: '',
|
||||
tabs: [],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
}));
|
||||
@@ -28,6 +30,12 @@ export function EmbeddableIframeAPI(props: {
|
||||
const router = useRouter();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
React.useEffect(() => {
|
||||
return chatController.on('open', () => {
|
||||
router.push(`${baseURL}/assistant`);
|
||||
});
|
||||
}, [router, baseURL, chatController]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.parent === window) {
|
||||
return;
|
||||
@@ -93,23 +101,114 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
|
||||
* Display the buttons defined by the parent window.
|
||||
*/
|
||||
export function EmbeddableIframeButtons() {
|
||||
const buttons = useEmbeddableConfiguration((state) => state.buttons);
|
||||
const { actions: configuredActions, buttons: configuredButtons = [] } =
|
||||
useEmbeddableConfiguration((state) => state);
|
||||
const actions = configuredActions.length > 0 ? configuredActions : configuredButtons;
|
||||
|
||||
return (
|
||||
<>
|
||||
{buttons.map((button) => (
|
||||
{actions.length > 0 && (
|
||||
<hr className="my-2 border-0 border-tint-subtle border-b first:hidden" />
|
||||
)}
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={button.label}
|
||||
key={action.label}
|
||||
size="default"
|
||||
variant="blank"
|
||||
icon={button.icon}
|
||||
label={button.label}
|
||||
icon={action?.icon ?? 'square-question'}
|
||||
label={action?.label}
|
||||
iconOnly
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
disabled={!action.onClick}
|
||||
onClick={() => {
|
||||
button.onClick();
|
||||
action.onClick?.();
|
||||
}}
|
||||
tooltipProps={{
|
||||
contentProps: {
|
||||
side: 'right',
|
||||
},
|
||||
}}
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddableIframeTabs(props: {
|
||||
ref?: React.RefObject<HTMLDivElement | null>;
|
||||
active?: string;
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
}) {
|
||||
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
||||
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
|
||||
|
||||
const { assistants, config } = useAI();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const tabs = [
|
||||
config.aiMode === CustomizationAIMode.Assistant &&
|
||||
assistants[0] &&
|
||||
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
|
||||
? {
|
||||
key: 'assistant',
|
||||
label: assistants[0].label,
|
||||
icon: assistants[0].icon,
|
||||
onClick: () => {
|
||||
router.push(`${baseURL}/assistant`);
|
||||
},
|
||||
}
|
||||
: null,
|
||||
configuredTabs.includes('docs') || configuredTabs.length === 0
|
||||
? {
|
||||
key: 'docs',
|
||||
label: siteTitle,
|
||||
icon: 'book-open',
|
||||
onClick: () => {
|
||||
router.push(`${baseURL}/page/`);
|
||||
},
|
||||
}
|
||||
: null,
|
||||
].filter((tab) => tab !== null);
|
||||
|
||||
// Override the active tab if it doesn't match the configured tabs
|
||||
React.useEffect(() => {
|
||||
const hasAssistant = tabs.find((tab) => tab.key === 'assistant');
|
||||
const hasDocs = tabs.find((tab) => tab.key === 'docs');
|
||||
if (!hasAssistant && !hasDocs) {
|
||||
// No valid tabs, do not redirect
|
||||
return;
|
||||
}
|
||||
if (active === 'assistant' && !hasAssistant) {
|
||||
router.replace(`${baseURL}/page`);
|
||||
} else if (active === 'docs' && !hasDocs) {
|
||||
router.replace(`${baseURL}/assistant`);
|
||||
}
|
||||
}, [tabs, baseURL, router, active]);
|
||||
|
||||
return tabs.length > 1 || actions.length > 0 ? (
|
||||
<div className="flex flex-col gap-2" ref={ref}>
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.key}
|
||||
data-testid={`embed-tab-${tab.key}`}
|
||||
label={tab.label}
|
||||
size="default"
|
||||
variant="blank"
|
||||
icon={tab.icon}
|
||||
active={tab.key === active}
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
iconOnly
|
||||
onClick={tab.onClick}
|
||||
tooltipProps={{
|
||||
contentProps: {
|
||||
side: 'right',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
} from '@/components/SiteLayout';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
|
||||
import { SpaceLayoutServerContext } from '../SpaceLayout';
|
||||
import { TrademarkLink } from '../TableOfContents/Trademark';
|
||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
|
||||
|
||||
type EmbeddableRootLayoutProps = {
|
||||
@@ -29,12 +31,16 @@ export async function EmbeddableRootLayout({
|
||||
return (
|
||||
<CustomizationRootLayout context={context}>
|
||||
<SiteLayoutClientContexts
|
||||
forcedTheme={context.customization.themes.default}
|
||||
forcedTheme={
|
||||
context.customization.themes.toggeable
|
||||
? undefined
|
||||
: context.customization.themes.default
|
||||
}
|
||||
externalLinksTarget={context.customization.externalLinks.target}
|
||||
contextId={context.contextId}
|
||||
>
|
||||
<AIContextProvider
|
||||
aiMode={CustomizationAIMode.Assistant}
|
||||
aiMode={context.customization.ai.mode}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
>
|
||||
<SpaceLayoutServerContext
|
||||
@@ -46,9 +52,19 @@ export async function EmbeddableRootLayout({
|
||||
asEmbeddable: true,
|
||||
}}
|
||||
>
|
||||
<div className="fixed inset-0 flex flex-col">{children}</div>
|
||||
<NavigationLoader />
|
||||
<div className="fixed inset-0 flex flex-col">
|
||||
{children}
|
||||
{context.customization.trademark.enabled ? (
|
||||
<TrademarkLink
|
||||
className="rounded-none border-tint-solid/3 border-t bg-tint-solid/1 px-4 py-2.5 text-tint/8 ring-0"
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Embed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<EmbeddableIframeAPI
|
||||
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
|
||||
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||
/>
|
||||
</SpaceLayoutServerContext>
|
||||
</AIContextProvider>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
||||
className={tcls(
|
||||
'border-tint-subtle border-t',
|
||||
// If the footer only contains a mode toggle, we only show it on smaller screens
|
||||
mobileOnly ? '@7xl:hidden' : null
|
||||
mobileOnly ? 'xl:hidden' : null
|
||||
)}
|
||||
>
|
||||
<div className="motion-safe:transition-[padding] motion-safe:duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
|
||||
@@ -25,12 +25,12 @@ export function Header(props: {
|
||||
};
|
||||
}) {
|
||||
const { context, withTopHeader, variants } = props;
|
||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||
const { siteSpace, visibleSiteSpaces, visibleSections, customization } = context;
|
||||
|
||||
const withSections = Boolean(
|
||||
sections &&
|
||||
(sections.list.length > 1 || // Show section tabs if there are at least 2 sections or at least 1 section group
|
||||
sections.list.some((s) => s.object === 'site-section-group'))
|
||||
visibleSections &&
|
||||
(visibleSections.list.length > 1 || // Show section tabs if there are at least 2 sections or at least 1 section group
|
||||
visibleSections.list.some((s) => s.object === 'site-section-group'))
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -139,20 +139,22 @@ export function Header(props: {
|
||||
style={customization.styling.search}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
visibleSections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' && s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={sections ? sections.list.length > 1 : false}
|
||||
withSections={
|
||||
visibleSections ? visibleSections.list.length > 1 : false
|
||||
}
|
||||
section={
|
||||
sections
|
||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||
encodeClientSiteSections(context, sections).current
|
||||
visibleSections
|
||||
? // Client-encode to avoid a serialization issue that was causing the language selector to disappear
|
||||
encodeClientSiteSections(context, visibleSections).current
|
||||
: undefined
|
||||
}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
siteSpaces={visibleSiteSpaces}
|
||||
viewport={!withTopHeader ? 'mobile' : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -196,9 +198,9 @@ export function Header(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections && withSections ? (
|
||||
{visibleSections && withSections ? (
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
|
||||
<SiteSectionTabs sections={encodeClientSiteSections(context, visibleSections)}>
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { useScrollListener } from '../hooks/useScrollListener';
|
||||
import { Button, type ButtonProps } from '../primitives';
|
||||
|
||||
const globalClassName = 'navigation-open';
|
||||
|
||||
@@ -16,18 +14,22 @@ const SCROLL_DISTANCE = 320;
|
||||
/**
|
||||
* Button to show/hide the table of content on mobile.
|
||||
*/
|
||||
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) {
|
||||
export function HeaderMobileMenu(props: ButtonProps) {
|
||||
const language = useLanguage();
|
||||
|
||||
const pathname = usePathname();
|
||||
const hasScrollRef = useRef(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleNavigation = () => {
|
||||
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
|
||||
document.body.classList.remove(globalClassName);
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
document.body.classList.add(globalClassName);
|
||||
window.scrollTo(0, 0);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,16 +44,15 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-label={tString(language, 'table_of_contents_button_label')}
|
||||
<Button
|
||||
icon="bars"
|
||||
iconOnly
|
||||
variant="blank"
|
||||
size="default"
|
||||
label={tString(language, 'table_of_contents_button_label')}
|
||||
onClick={toggleNavigation}
|
||||
className={tcls(
|
||||
'flex flex-row items-center rounded-sm straight-corners:rounded-xs px-2 py-1',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<Icon icon="bars" className="size-4 text-inherit" />
|
||||
</button>
|
||||
active={isOpen}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export async function generateSitePageViewport(context: GitBookSiteContext): Pro
|
||||
* A string concatenation of the site structure (sections and variants) titles.
|
||||
*/
|
||||
function getSiteStructureTitle(context: GitBookSiteContext): string | null {
|
||||
const { sections, siteSpace, siteSpaces } = context;
|
||||
const { visibleSections: sections, siteSpace, visibleSiteSpaces: siteSpaces } = context;
|
||||
|
||||
const title = [];
|
||||
if (
|
||||
@@ -259,7 +259,7 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const { customization, sections } = context;
|
||||
const { customization, visibleSections } = context;
|
||||
const { page, ancestors } = pageTarget;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
@@ -270,7 +270,7 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
);
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 0);
|
||||
const withSections = Boolean(visibleSections && visibleSections.list.length > 0);
|
||||
|
||||
const document = await getPageDocument(context, page);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export function SiteSectionTabs(props: {
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const [offset, setOffset] = React.useState<number | null>(null);
|
||||
const [value, setValue] = React.useState<string | undefined>();
|
||||
@@ -41,12 +42,15 @@ export function SiteSectionTabs(props: {
|
||||
|
||||
React.useEffect(() => {
|
||||
const trigger = currentTriggerRef.current;
|
||||
if (!value || !trigger) {
|
||||
const container = containerRef.current;
|
||||
if (!value || !trigger || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerWidth = trigger.getBoundingClientRect().width;
|
||||
const triggerLeft = trigger.getBoundingClientRect().left;
|
||||
const triggerWidth = trigger.getBoundingClientRect().width - SCREEN_OFFSET;
|
||||
const triggerLeft =
|
||||
trigger.getBoundingClientRect().left -
|
||||
(window.innerWidth - container.getBoundingClientRect().width) / 2;
|
||||
setOffset(triggerLeft + triggerWidth / 2);
|
||||
}, [value]);
|
||||
|
||||
@@ -58,6 +62,7 @@ export function SiteSectionTabs(props: {
|
||||
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]',
|
||||
className
|
||||
)}
|
||||
ref={containerRef}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
skipDelayDuration={500}
|
||||
@@ -141,14 +146,14 @@ export function SiteSectionTabs(props: {
|
||||
{children}
|
||||
|
||||
<div
|
||||
className="fixed top-full left-0 z-20 flex w-full"
|
||||
className="absolute top-full left-0 z-20 flex w-full"
|
||||
style={{
|
||||
padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`,
|
||||
}}
|
||||
>
|
||||
<NavigationMenu.Viewport
|
||||
className={tcls(
|
||||
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg transition-transform duration-250 ease-in-out',
|
||||
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg ease-in-out',
|
||||
'-mt-0.5 w-full md:w-max',
|
||||
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
|
||||
"[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready.
|
||||
@@ -156,7 +161,7 @@ export function SiteSectionTabs(props: {
|
||||
style={{
|
||||
translate:
|
||||
!isMobile && offset
|
||||
? `clamp(0px, calc(${offset}px - ${SCREEN_OFFSET}px - 50%), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
|
||||
? `clamp(0px, calc(${offset}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
|
||||
: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
|
||||
display: offset === null ? 'none' : undefined,
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) {
|
||||
// Only the properties used by categorizeVariants are required for these tests
|
||||
siteSpace: current,
|
||||
siteSpaces: all,
|
||||
visibleSiteSpaces: all,
|
||||
} as unknown as Parameters<typeof categorizeVariants>[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -99,11 +99,11 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
*/
|
||||
export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
const { context, children } = props;
|
||||
const { siteSpace, customization, sections, siteSpaces } = context;
|
||||
const { siteSpace, customization, visibleSections, visibleSiteSpaces } = context;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 1);
|
||||
const withSections = Boolean(visibleSections && visibleSections.list.length > 1);
|
||||
const variants = categorizeVariants(context);
|
||||
|
||||
const withFooter =
|
||||
@@ -181,25 +181,28 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
visibleSections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={withSections}
|
||||
section={sections?.current}
|
||||
section={visibleSections?.current}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
siteSpaces={visibleSiteSpaces}
|
||||
className="max-lg:hidden"
|
||||
viewport="desktop"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!withTopHeader && withSections && sections && (
|
||||
{!withTopHeader && withSections && visibleSections && (
|
||||
<SiteSectionList
|
||||
className={tcls('hidden', 'lg:block')}
|
||||
sections={encodeClientSiteSections(context, sections)}
|
||||
sections={encodeClientSiteSections(
|
||||
context,
|
||||
visibleSections
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
* Categorize the variants of the space into generic and translation variants.
|
||||
*/
|
||||
export function categorizeVariants(context: GitBookSiteContext) {
|
||||
const { siteSpace, siteSpaces } = context;
|
||||
const { siteSpace, visibleSiteSpaces: siteSpaces } = context;
|
||||
const currentLanguage = siteSpace.space.language;
|
||||
|
||||
// Get all languages of the variants.
|
||||
|
||||
@@ -13,8 +13,9 @@ export async function TableOfContents(props: {
|
||||
context: GitBookSiteContext;
|
||||
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
|
||||
innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list
|
||||
className?: string;
|
||||
}) {
|
||||
const { innerHeader, context, header } = props;
|
||||
const { innerHeader, context, header, className } = props;
|
||||
const { customization, revision } = context;
|
||||
|
||||
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
|
||||
@@ -74,7 +75,8 @@ export async function TableOfContents(props: {
|
||||
'gap-4',
|
||||
|
||||
'navigation-open:border-b',
|
||||
'border-tint-subtle'
|
||||
'border-tint-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{header && header}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { Link } from '../primitives';
|
||||
export function Trademark(props: {
|
||||
context: GitBookSpaceContext;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { className, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -55,10 +57,12 @@ export function Trademark(props: {
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
|
||||
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
|
||||
'page-no-toc:before:to-transparent!'
|
||||
'page-no-toc:before:to-transparent!',
|
||||
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TrademarkLink {...props} />
|
||||
<TrademarkLink {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,8 +73,9 @@ export function Trademark(props: {
|
||||
export function TrademarkLink(props: {
|
||||
context: GitBookSpaceContext;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { context, placement } = props;
|
||||
const { context, placement, className } = props;
|
||||
const { space } = context;
|
||||
const language = getSpaceLanguage(context);
|
||||
|
||||
@@ -85,9 +90,6 @@ export function TrademarkLink(props: {
|
||||
href={url.toString()}
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
// 'lg:max-xl:page-no-toc:text-xs',
|
||||
// 'lg:max-xl:page-no-toc:px-3',
|
||||
// 'lg:max-xl:page-no-toc:py-3',
|
||||
'font-semibold',
|
||||
'text-tint',
|
||||
|
||||
@@ -113,29 +115,17 @@ export function TrademarkLink(props: {
|
||||
'ring-tint-subtle',
|
||||
|
||||
'transition-colors',
|
||||
'pointer-events-auto'
|
||||
'pointer-events-auto',
|
||||
|
||||
className
|
||||
)}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon="gitbook"
|
||||
className={tcls(
|
||||
'size-5',
|
||||
// 'lg:max-xl:page-no-toc:size-4',
|
||||
'shrink-0'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={tcls(
|
||||
'ml-3'
|
||||
// 'lg:max-xl:page-no-toc:ml-2'
|
||||
)}
|
||||
>
|
||||
{t(language, 'powered_by_gitbook')}
|
||||
</span>
|
||||
<Icon icon="gitbook" className={tcls('size-5', 'shrink-0')} />
|
||||
<span className={tcls('ml-3')}>{t(language, 'powered_by_gitbook')}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function encodeClientTableOfContents(
|
||||
result.push(
|
||||
removeUndefined({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
title: page.linkTitle || page.title,
|
||||
href,
|
||||
emoji: page.emoji,
|
||||
icon: page.icon,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { Link, type LinkInsightsProps } from './Link';
|
||||
import { useClassnames } from './StyleProvider';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { Tooltip, type TooltipProps } from './Tooltip';
|
||||
|
||||
export type ButtonProps = {
|
||||
href?: string;
|
||||
@@ -20,6 +20,7 @@ export type ButtonProps = {
|
||||
trailing?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
active?: boolean;
|
||||
tooltipProps?: TooltipProps;
|
||||
} & LinkInsightsProps &
|
||||
React.HTMLAttributes<HTMLElement>;
|
||||
|
||||
@@ -112,6 +113,7 @@ export const Button = React.forwardRef<
|
||||
active,
|
||||
trailing,
|
||||
disabled,
|
||||
tooltipProps,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
@@ -133,18 +135,29 @@ export const Button = React.forwardRef<
|
||||
);
|
||||
const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
|
||||
|
||||
let iconElement = null;
|
||||
if (icon) {
|
||||
if (React.isValidElement(icon)) {
|
||||
type IconElement = React.ReactElement<React.SVGProps<SVGSVGElement>>;
|
||||
iconElement = React.cloneElement(icon as IconElement, {
|
||||
className: tcls(
|
||||
'button-leading-icon size-[1em] shrink-0',
|
||||
(icon as IconElement).props.className
|
||||
),
|
||||
});
|
||||
} else {
|
||||
iconElement = (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
className={tcls('button-leading-icon size-[1em] shrink-0')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon ? (
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
className={tcls('button-leading-icon size-[1em] shrink-0')}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
) : null}
|
||||
{iconElement}
|
||||
{iconOnly || (!children && !label) ? null : (
|
||||
<span className="button-content truncate">{children ?? label}</span>
|
||||
)}
|
||||
@@ -184,9 +197,13 @@ export const Button = React.forwardRef<
|
||||
|
||||
return (children || iconOnly) && label ? (
|
||||
<Tooltip
|
||||
rootProps={{ open: disabled === true ? false : undefined }}
|
||||
rootProps={{
|
||||
open: disabled === true ? false : undefined,
|
||||
...tooltipProps?.rootProps,
|
||||
}}
|
||||
label={label}
|
||||
triggerProps={{ disabled }}
|
||||
triggerProps={{ disabled, ...tooltipProps?.triggerProps }}
|
||||
contentProps={{ ...tooltipProps?.contentProps }}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function HoverCard(
|
||||
<RadixHoverCard.Portal>
|
||||
<RadixHoverCard.Content
|
||||
side={props.side ?? 'top'}
|
||||
className="z-40 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
|
||||
className="pointer-events-none z-40 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
|
||||
@@ -78,8 +78,8 @@ export function Link(props: LinkProps) {
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const isExternalWithOrigin = isExternalLink(href, window.location.origin);
|
||||
// Only trigger navigation context for internal links without modifier keys (i.e. open in new tab).
|
||||
if (!isExternal && !event.ctrlKey && !event.metaKey) {
|
||||
// Only trigger navigation context for internal links in the same window without modifier keys (i.e. open in new tab).
|
||||
if (!isExternal && target !== '_blank' && !event.ctrlKey && !event.metaKey) {
|
||||
onNavigationClick(href);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import { tcls } from '@/lib/tailwind';
|
||||
import * as RadixTooltip from '@radix-ui/react-tooltip';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type TooltipProps = {
|
||||
rootProps?: RadixTooltip.TooltipProps;
|
||||
triggerProps?: RadixTooltip.TooltipTriggerProps;
|
||||
contentProps?: RadixTooltip.TooltipContentProps;
|
||||
};
|
||||
|
||||
export function Tooltip(props: {
|
||||
children: React.ReactNode;
|
||||
label?: string | React.ReactNode;
|
||||
|
||||
@@ -120,9 +120,15 @@ export type GitBookSiteContext = GitBookSpaceContext & {
|
||||
/** All site spaces in the current section / or entire site */
|
||||
siteSpaces: SiteSpace[];
|
||||
|
||||
/** Site spaces that are not hidden (visible to visitors). */
|
||||
visibleSiteSpaces: SiteSpace[];
|
||||
|
||||
/** Sections of the site. */
|
||||
sections: null | SiteSections;
|
||||
|
||||
/** Sections filtered to visible site spaces only. */
|
||||
visibleSections: null | SiteSections;
|
||||
|
||||
/** Customizations of the site. */
|
||||
customization: SiteCustomizationSettings;
|
||||
|
||||
@@ -261,9 +267,16 @@ export async function fetchSiteContextByIds(
|
||||
const sections = ids.siteSection
|
||||
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||
: null;
|
||||
const visibleSections = ids.siteSection
|
||||
? parseVisibleSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||
: null;
|
||||
|
||||
// Parse the current siteSpace and siteSpaces based on the site structure type.
|
||||
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
|
||||
const {
|
||||
siteSpaces,
|
||||
siteSpace,
|
||||
visibleSiteSpaces,
|
||||
}: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace; visibleSiteSpaces: SiteSpace[] } = (() => {
|
||||
if (siteStructure.type === 'siteSpaces') {
|
||||
const siteSpaces = siteStructure.structure;
|
||||
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||
@@ -274,7 +287,7 @@ export async function fetchSiteContextByIds(
|
||||
);
|
||||
}
|
||||
|
||||
return { siteSpaces: filterHiddenSiteSpaces(siteSpaces), siteSpace };
|
||||
return { siteSpaces, siteSpace, visibleSiteSpaces: filterHiddenSiteSpaces(siteSpaces) };
|
||||
}
|
||||
|
||||
if (siteStructure.type === 'sections') {
|
||||
@@ -295,7 +308,11 @@ export async function fetchSiteContextByIds(
|
||||
);
|
||||
}
|
||||
|
||||
return { siteSpaces: filterHiddenSiteSpaces(siteSpaces), siteSpace };
|
||||
return {
|
||||
siteSpaces,
|
||||
siteSpace,
|
||||
visibleSiteSpaces: filterHiddenSiteSpaces(siteSpaces),
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
@@ -327,10 +344,12 @@ export async function fetchSiteContextByIds(
|
||||
organizationId: ids.organization,
|
||||
site,
|
||||
siteSpaces,
|
||||
visibleSiteSpaces,
|
||||
siteSpace,
|
||||
customization,
|
||||
structure: siteStructure,
|
||||
sections,
|
||||
visibleSections,
|
||||
scripts,
|
||||
contextId: ids.contextId,
|
||||
isFallback: ids.isFallback,
|
||||
@@ -434,13 +453,56 @@ function filterHiddenSiteSpaces(siteSpaces: SiteSpace[]): SiteSpace[] {
|
||||
}
|
||||
|
||||
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
||||
const sectionsAndGroups = getSiteStructureSections(structure);
|
||||
const section = parseCurrentSection(structure, siteSectionId);
|
||||
assert(section, `couldn't find section "${siteSectionId}" in site structure`);
|
||||
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
|
||||
}
|
||||
|
||||
function parseVisibleSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||
const { list: sectionsAndGroups, current: section } = parseSiteSectionsAndGroups(
|
||||
structure,
|
||||
siteSectionId
|
||||
);
|
||||
const visibleSectionsAndGroups = filterSectionsAndGroupsWithHiddenSiteSpaces(sectionsAndGroups);
|
||||
return { list: visibleSectionsAndGroups, current: section } satisfies SiteSections;
|
||||
}
|
||||
|
||||
function parseCurrentSection(structure: SiteStructure, siteSectionId: string) {
|
||||
const sections = getSiteStructureSections(structure, { ignoreGroups: true });
|
||||
return sections.find((section) => section.id === siteSectionId);
|
||||
}
|
||||
|
||||
type SectionOrGroup = SiteSection | SiteSectionGroup;
|
||||
|
||||
/**
|
||||
* Filter out sections where all site spaces are hidden and groups that become empty after filtering.
|
||||
*/
|
||||
function filterSectionsAndGroupsWithHiddenSiteSpaces(
|
||||
sectionsOrGroups: SectionOrGroup[]
|
||||
): SectionOrGroup[] {
|
||||
return sectionsOrGroups
|
||||
.map((entry) => {
|
||||
if (entry.object === 'site-section') {
|
||||
return sectionHasOnlyHiddenSiteSpaces(entry) ? null : entry;
|
||||
}
|
||||
|
||||
const visibleChildren: SectionOrGroup[] = filterSectionsAndGroupsWithHiddenSiteSpaces(
|
||||
entry.children
|
||||
);
|
||||
|
||||
if (visibleChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
children: visibleChildren,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is SiteSection | SiteSectionGroup => Boolean(entry));
|
||||
}
|
||||
|
||||
function sectionHasOnlyHiddenSiteSpaces(section: SiteSection) {
|
||||
return section.siteSpaces.every((siteSpace) => siteSpace.hidden);
|
||||
}
|
||||
|
||||
@@ -55,5 +55,16 @@ export function getEmbeddableLinker(linker: GitBookLinker): GitBookLinker {
|
||||
spaceBasePath: joinPath(override.spaceBasePath, '~gitbook/embed/page'),
|
||||
});
|
||||
},
|
||||
|
||||
toLinkForContent(rawURL: string): string {
|
||||
const result = linker.toLinkForContent(rawURL);
|
||||
// If the link is not relative or already an embed, return it as is
|
||||
if (result.includes('~gitbook/embed') || !result.startsWith('/')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If the link is relative, assume it's a section link and append the embed path
|
||||
return joinPath(result, '~gitbook/embed/page');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export async function resolveContentRef(
|
||||
const page = resolvePageResult?.page;
|
||||
const ancestors =
|
||||
resolvePageResult?.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
label: ancestor.linkTitle || ancestor.title,
|
||||
icon:
|
||||
ancestor.emoji || ancestor.icon ? (
|
||||
<PageIcon
|
||||
@@ -153,9 +153,9 @@ export async function resolveContentRef(
|
||||
|
||||
// Compute the text to display for the link
|
||||
if (anchor) {
|
||||
text = page.title;
|
||||
text = page.linkTitle || page.title;
|
||||
ancestors.push({
|
||||
label: page.title,
|
||||
label: page.linkTitle || page.title,
|
||||
icon: <PageIcon page={page} style={iconStyle} />,
|
||||
href,
|
||||
});
|
||||
@@ -177,7 +177,7 @@ export async function resolveContentRef(
|
||||
parentPage && contentRef.page === parentPage.id && parentPage.type === 'group'
|
||||
? parentPage
|
||||
: page;
|
||||
text = pageOrGroup.title;
|
||||
text = pageOrGroup.linkTitle || pageOrGroup.title;
|
||||
emoji = isCurrentPage ? undefined : page.emoji;
|
||||
icon = <PageIcon page={pageOrGroup} style={iconStyle} />;
|
||||
}
|
||||
|
||||
@@ -612,6 +612,7 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
}
|
||||
|
||||
switch (pathname) {
|
||||
case '~gitbook/embed':
|
||||
case '~gitbook/embed/assistant':
|
||||
case '~gitbook/icon':
|
||||
return { pathname };
|
||||
|
||||
Reference in New Issue
Block a user