mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 14:45:17 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50cafe9ec6 | |||
| 2f05549c72 | |||
| 2da0ccf4ba |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/embed": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve Docs Embed with separate Assistant and Docs tabs
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix CORS error when using embed script.js directly
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "gitbook",
|
||||
@@ -345,7 +346,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.153.0",
|
||||
"@gitbook/api": "0.151.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -726,7 +727,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.153.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ArNPFoqwId4Flicz8xPEdGqXQkzxyxP7S8Uv3wIfCX2e4cLhpcS7xCJGEHOJrqe1tNs1ovT1N2MWfpdIqzXqig=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.151.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-5d9+rZ2u6CKKIiVHO1Toyk+7wHtTOXmP0+sVIE3teRkceX4z5FGIIpa4XsFeKC9XeosncvehdshPaOGQtSDpTQ=="],
|
||||
|
||||
"@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.153.0",
|
||||
"@gitbook/api": "0.151.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
+8
-370
@@ -1,61 +1,24 @@
|
||||
# GitBook Docs Embed (`@gitbook/embed`)
|
||||
# `@gitbook/embed`
|
||||
|
||||
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.
|
||||
Embed the GitBook Docs Assistant in your product or website.
|
||||
|
||||
# Usage
|
||||
|
||||
## As a standalone script from your docs site
|
||||
## As a script from your docs site
|
||||
|
||||
All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website.
|
||||
All GitBook docs site includes a script to easily embed the docs assistant 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 `docs.company.com` with your docs site hostname.
|
||||
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.
|
||||
|
||||
```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:
|
||||
@@ -67,46 +30,10 @@ const gitbook = createGitBook({
|
||||
siteURL: 'https://docs.company.com'
|
||||
});
|
||||
|
||||
// Create an iframe and get its URL
|
||||
const iframe = document.createElement('iframe');
|
||||
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'
|
||||
}
|
||||
}
|
||||
});
|
||||
iframe.src = gitbook.getFrameURL();
|
||||
|
||||
// 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
|
||||
@@ -114,298 +41,9 @@ frame.on('close', () => {
|
||||
After installing the NPM package, you can import prebuilt React components:
|
||||
|
||||
```tsx
|
||||
import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
||||
|
||||
<GitBookProvider siteURL="https://docs.company.com">
|
||||
<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={[/* ... */]}
|
||||
/>
|
||||
<GitBookAssistantFrame />
|
||||
</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`;
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
||||
|
||||
if (frameOptions.visitor?.token) {
|
||||
url.searchParams.set('token', frameOptions.visitor.token);
|
||||
|
||||
@@ -64,9 +64,8 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
tabs: ['assistant', 'docs'],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
buttons: [],
|
||||
welcomeMessage: '',
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export type GitBookToolDefinition = AIToolDefinition & {
|
||||
/**
|
||||
* Custom button definition to be passed to the embeddable GitBook.
|
||||
*/
|
||||
export type GitBookEmbeddableActionDefinition = {
|
||||
export type GitBookEmbeddableButtonDefinition = {
|
||||
/**
|
||||
* Icon to be displayed in the button.
|
||||
*/
|
||||
@@ -41,26 +41,16 @@ export type GitBookEmbeddableActionDefinition = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Overall configuration for the layout of the GitBook embed.
|
||||
* Overall configuration for the layout of the embeddable GitBook.
|
||||
*/
|
||||
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[];
|
||||
|
||||
/**
|
||||
* Additional buttons to be displayed in the header of the GitBook embed.
|
||||
* @deprecated Use `actions` instead.
|
||||
* Buttons to be displayed in the header of the embeddable GitBook.
|
||||
*/
|
||||
buttons?: GitBookEmbeddableActionDefinition[];
|
||||
buttons: GitBookEmbeddableButtonDefinition[];
|
||||
|
||||
/** Message to be displayed in the welcome page. */
|
||||
greeting: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
welcomeMessage: 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, actions, greeting, suggestions, tools } = props;
|
||||
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const gitbook = useGitBook();
|
||||
@@ -33,13 +33,12 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
|
||||
useEffect(() => {
|
||||
gitbookFrame?.configure({
|
||||
tabs: ['assistant', 'docs'],
|
||||
actions,
|
||||
greeting,
|
||||
buttons,
|
||||
welcomeMessage,
|
||||
suggestions,
|
||||
tools,
|
||||
});
|
||||
}, [gitbookFrame, actions, greeting, suggestions, tools]);
|
||||
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
|
||||
@@ -31,50 +31,24 @@ type StandaloneCalls =
|
||||
// Clear the chat
|
||||
| ['clearChat']
|
||||
// Configure the embed
|
||||
| ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>]
|
||||
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||
// 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" data-icon="${frameConfiguration.button.icon}"></span>
|
||||
<span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span>
|
||||
<span id="gitbook-widget-button-icon"></span>
|
||||
<span id="gitbook-widget-button-label">Ask</span>
|
||||
`;
|
||||
|
||||
const widgetWindow = document.createElement('div');
|
||||
@@ -84,6 +58,17 @@ 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(
|
||||
@@ -150,31 +135,27 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
case 'postUserMessage':
|
||||
getIframe().frame.postUserMessage(args[1]);
|
||||
break;
|
||||
case 'configure': {
|
||||
const settings = args[1];
|
||||
case 'configure':
|
||||
frameConfiguration = {
|
||||
...frameConfiguration,
|
||||
...settings,
|
||||
...args[1],
|
||||
};
|
||||
// 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,18 +113,6 @@
|
||||
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');
|
||||
}
|
||||
|
||||
+2
-23
@@ -1,28 +1,7 @@
|
||||
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(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}
|
||||
/>
|
||||
);
|
||||
export default async function Page() {
|
||||
return <EmbeddableAssistantPage />;
|
||||
}
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
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/`);
|
||||
}
|
||||
}
|
||||
+2
-23
@@ -1,28 +1,7 @@
|
||||
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';
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
export default async function Page() {
|
||||
return <EmbeddableAssistantPage />;
|
||||
}
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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/`);
|
||||
}
|
||||
}
|
||||
+1
-9
@@ -7,14 +7,6 @@ import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
const EMBEDDABLE_RESPONSE_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
|
||||
};
|
||||
|
||||
/**
|
||||
* This route is used to serve the assistant.js script.
|
||||
*/
|
||||
@@ -77,8 +69,8 @@ export async function GET(
|
||||
`,
|
||||
{
|
||||
headers: {
|
||||
...EMBEDDABLE_RESPONSE_HEADERS,
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -88,7 +88,6 @@ export function useAI(): AIContext {
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
className="size-4"
|
||||
/>
|
||||
),
|
||||
open: (query?: string) => {
|
||||
|
||||
@@ -87,19 +87,6 @@ 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;
|
||||
@@ -109,11 +96,6 @@ 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);
|
||||
@@ -141,17 +123,6 @@ 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.
|
||||
*/
|
||||
@@ -166,9 +137,6 @@ 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();
|
||||
@@ -181,8 +149,6 @@ 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
|
||||
@@ -196,8 +162,6 @@ export function AIChatProvider(props: {
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
|
||||
notify(eventsRef.current.get('close'), {});
|
||||
}, [setSearchState]);
|
||||
|
||||
// Stream a message with the AI backend
|
||||
@@ -415,14 +379,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -482,34 +440,14 @@ 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, onEvent]);
|
||||
}, [onOpen, onClose, onClear, onPostMessage]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSubtitle,
|
||||
EmbeddableFrameTitle,
|
||||
} from '../Embeddable/EmbeddableFrame';
|
||||
@@ -79,31 +78,29 @@ 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">
|
||||
<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>
|
||||
<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>
|
||||
</EmbeddableFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -114,14 +111,13 @@ export function AIChat() {
|
||||
*/
|
||||
export function AIChatDynamicIcon(props: {
|
||||
trademark: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { trademark, className } = props;
|
||||
const { trademark } = props;
|
||||
const chat = useAIChatState();
|
||||
|
||||
return (
|
||||
<AIChatIcon
|
||||
className={tcls('size-5 text-tint', className)}
|
||||
className="size-5 text-tint"
|
||||
trademark={trademark}
|
||||
state={
|
||||
chat.error
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useAIChatController, useAIChatState } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
|
||||
/**
|
||||
* Button to control the chat (clear, etc.)
|
||||
@@ -14,16 +15,26 @@ export function AIChatControlButton() {
|
||||
const chatController = useAIChatController();
|
||||
|
||||
return chat.messages.length > 0 ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
chatController.clear();
|
||||
}}
|
||||
iconOnly
|
||||
icon="trash-can"
|
||||
label={t(language, 'ai_chat_clear_conversation')}
|
||||
variant="blank"
|
||||
size="default"
|
||||
className="animate-blur-in-slow"
|
||||
/>
|
||||
<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>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ export function Hint({
|
||||
className={tcls(
|
||||
'hint',
|
||||
'transition-colors',
|
||||
'rounded-corners:rounded-md',
|
||||
'rounded-md',
|
||||
hasHeading ? 'rounded-l-sm' : null,
|
||||
'straight-corners:rounded-none',
|
||||
'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-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'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="can-override-bg absolute top-9 bottom-2 left-3.5 w-px bg-primary-7 theme-muted:bg-tint-6" />
|
||||
<div className="can-override-bg absolute top-9 bottom-2 left-3.5 w-px bg-primary-7 theme-muted:bg-primary-subtle" />
|
||||
</div>
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
|
||||
@@ -18,36 +18,20 @@ import {
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSidebar,
|
||||
EmbeddableFrameTitle,
|
||||
} from './EmbeddableFrame';
|
||||
import {
|
||||
EmbeddableIframeButtons,
|
||||
EmbeddableIframeTabs,
|
||||
useEmbeddableConfiguration,
|
||||
} from './EmbeddableIframeAPI';
|
||||
|
||||
type EmbeddableAIChatProps = {
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
};
|
||||
import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI';
|
||||
|
||||
/**
|
||||
* Embeddable AI chat window in an iframe.
|
||||
*/
|
||||
export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
const { baseURL, siteTitle } = props;
|
||||
export function EmbeddableAIChat() {
|
||||
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(() => {
|
||||
@@ -62,45 +46,28 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
|
||||
);
|
||||
}, [trackEvent]);
|
||||
|
||||
const tabsRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<EmbeddableFrame>
|
||||
<EmbeddableFrameSidebar>
|
||||
<EmbeddableIframeTabs
|
||||
ref={tabsRef}
|
||||
active="assistant"
|
||||
baseURL={baseURL}
|
||||
siteTitle={siteTitle}
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { EmbeddableAIChat } from './EmbeddableAIChat';
|
||||
|
||||
type EmbeddableAssistantPageProps = {
|
||||
baseURL: string;
|
||||
siteTitle: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable page component for the embed assistant page.
|
||||
*/
|
||||
export async function EmbeddableAssistantPage(props: EmbeddableAssistantPageProps) {
|
||||
return <EmbeddableAIChat baseURL={props.baseURL} siteTitle={props.siteTitle} />;
|
||||
export async function EmbeddableAssistantPage() {
|
||||
return <EmbeddableAIChat />;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
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 { 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 { Button } from '../primitives';
|
||||
import {
|
||||
EmbeddableFrame,
|
||||
EmbeddableFrameBody,
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameMain,
|
||||
EmbeddableFrameSidebar,
|
||||
EmbeddableFrameTitle,
|
||||
} from './EmbeddableFrame';
|
||||
import { EmbeddableIframeButtons, EmbeddableIframeTabs } from './EmbeddableIframeAPI';
|
||||
import { EmbeddableIframeButtons } from './EmbeddableIframeAPI';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
@@ -39,58 +33,32 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
|
||||
|
||||
return (
|
||||
<EmbeddableFrame className="site-background">
|
||||
<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}
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
'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 overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint',
|
||||
'flex h-full grow flex-col overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint',
|
||||
divProps.className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -28,19 +28,13 @@ 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 not-hydrated:animate-blur-in-slow select-none items-center gap-2 px-4 py-2.5 text-tint-strong">
|
||||
<div className="relative z-10 flex animate-fade-in-slow select-none items-center gap-2 px-4 py-2 text-tint-strong">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -51,7 +45,7 @@ export function EmbeddableFrameHeaderMain(props: {
|
||||
}) {
|
||||
const { children } = props;
|
||||
|
||||
return <div className="flex h-8 flex-1 flex-col justify-center">{children}</div>;
|
||||
return <div className="flex flex-1 flex-col">{children}</div>;
|
||||
}
|
||||
|
||||
export function EmbeddableFrameBody(props: {
|
||||
@@ -88,21 +82,10 @@ export function EmbeddableFrameSubtitle(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddableFrameSidebar(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
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;
|
||||
const { children } = props;
|
||||
|
||||
return <div className={tcls('flex gap-2', className)}>{children}</div>;
|
||||
return <div className="-mr-2 ml-auto flex gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,15 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
|
||||
import { createChannel } from 'bidc';
|
||||
import React from 'react';
|
||||
|
||||
import { useAI, useAIChatController } from '@/components/AI';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { useAIChatController } from '@/components/AI';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
|
||||
tabs: [],
|
||||
actions: [],
|
||||
greeting: { title: '', subtitle: '' },
|
||||
buttons: [],
|
||||
welcomeMessage: '',
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
}));
|
||||
@@ -30,12 +28,6 @@ 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;
|
||||
@@ -101,114 +93,23 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
|
||||
* Display the buttons defined by the parent window.
|
||||
*/
|
||||
export function EmbeddableIframeButtons() {
|
||||
const { actions: configuredActions, buttons: configuredButtons = [] } =
|
||||
useEmbeddableConfiguration((state) => state);
|
||||
const actions = configuredActions.length > 0 ? configuredActions : configuredButtons;
|
||||
const buttons = useEmbeddableConfiguration((state) => state.buttons);
|
||||
|
||||
return (
|
||||
<>
|
||||
{actions.length > 0 && (
|
||||
<hr className="my-2 border-0 border-tint-subtle border-b first:hidden" />
|
||||
)}
|
||||
{actions.map((action, index) => (
|
||||
{buttons.map((button) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
key={button.label}
|
||||
size="default"
|
||||
variant="blank"
|
||||
icon={action?.icon ?? 'square-question'}
|
||||
label={action?.label}
|
||||
icon={button.icon}
|
||||
label={button.label}
|
||||
iconOnly
|
||||
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
|
||||
disabled={!action.onClick}
|
||||
onClick={() => {
|
||||
action.onClick?.();
|
||||
button.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,10 +7,8 @@ import {
|
||||
} from '@/components/SiteLayout';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { SpaceLayoutServerContext } from '../SpaceLayout';
|
||||
import { TrademarkLink } from '../TableOfContents/Trademark';
|
||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
|
||||
|
||||
type EmbeddableRootLayoutProps = {
|
||||
@@ -31,16 +29,12 @@ export async function EmbeddableRootLayout({
|
||||
return (
|
||||
<CustomizationRootLayout context={context}>
|
||||
<SiteLayoutClientContexts
|
||||
forcedTheme={
|
||||
context.customization.themes.toggeable
|
||||
? undefined
|
||||
: context.customization.themes.default
|
||||
}
|
||||
forcedTheme={context.customization.themes.default}
|
||||
externalLinksTarget={context.customization.externalLinks.target}
|
||||
contextId={context.contextId}
|
||||
>
|
||||
<AIContextProvider
|
||||
aiMode={context.customization.ai.mode}
|
||||
aiMode={CustomizationAIMode.Assistant}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
>
|
||||
<SpaceLayoutServerContext
|
||||
@@ -52,19 +46,9 @@ export async function EmbeddableRootLayout({
|
||||
asEmbeddable: true,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div className="fixed inset-0 flex flex-col">{children}</div>
|
||||
<EmbeddableIframeAPI
|
||||
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||
baseURL={context.linker.toPathInSpace('~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 ? 'xl:hidden' : null
|
||||
mobileOnly ? '@7xl:hidden' : null
|
||||
)}
|
||||
>
|
||||
<div className="motion-safe:transition-[padding] motion-safe:duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } 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';
|
||||
|
||||
@@ -14,22 +16,18 @@ const SCROLL_DISTANCE = 320;
|
||||
/**
|
||||
* Button to show/hide the table of content on mobile.
|
||||
*/
|
||||
export function HeaderMobileMenu(props: ButtonProps) {
|
||||
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,15 +42,16 @@ export function HeaderMobileMenu(props: ButtonProps) {
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon="bars"
|
||||
iconOnly
|
||||
variant="blank"
|
||||
size="default"
|
||||
label={tString(language, 'table_of_contents_button_label')}
|
||||
onClick={toggleNavigation}
|
||||
active={isOpen}
|
||||
<button
|
||||
{...props}
|
||||
/>
|
||||
aria-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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ 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>();
|
||||
@@ -42,15 +41,12 @@ export function SiteSectionTabs(props: {
|
||||
|
||||
React.useEffect(() => {
|
||||
const trigger = currentTriggerRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!value || !trigger || !container) {
|
||||
if (!value || !trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerWidth = trigger.getBoundingClientRect().width - SCREEN_OFFSET;
|
||||
const triggerLeft =
|
||||
trigger.getBoundingClientRect().left -
|
||||
(window.innerWidth - container.getBoundingClientRect().width) / 2;
|
||||
const triggerWidth = trigger.getBoundingClientRect().width;
|
||||
const triggerLeft = trigger.getBoundingClientRect().left;
|
||||
setOffset(triggerLeft + triggerWidth / 2);
|
||||
}, [value]);
|
||||
|
||||
@@ -62,7 +58,6 @@ export function SiteSectionTabs(props: {
|
||||
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]',
|
||||
className
|
||||
)}
|
||||
ref={containerRef}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
skipDelayDuration={500}
|
||||
@@ -146,14 +141,14 @@ export function SiteSectionTabs(props: {
|
||||
{children}
|
||||
|
||||
<div
|
||||
className="absolute top-full left-0 z-20 flex w-full"
|
||||
className="fixed 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 ease-in-out',
|
||||
'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',
|
||||
'-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.
|
||||
@@ -161,7 +156,7 @@ export function SiteSectionTabs(props: {
|
||||
style={{
|
||||
translate:
|
||||
!isMobile && offset
|
||||
? `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`
|
||||
? `clamp(0px, calc(${offset}px - ${SCREEN_OFFSET}px - 50%), 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,
|
||||
}}
|
||||
|
||||
@@ -13,9 +13,8 @@ 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, className } = props;
|
||||
const { innerHeader, context, header } = props;
|
||||
const { customization, revision } = context;
|
||||
|
||||
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
|
||||
@@ -75,8 +74,7 @@ export async function TableOfContents(props: {
|
||||
'gap-4',
|
||||
|
||||
'navigation-open:border-b',
|
||||
'border-tint-subtle',
|
||||
className
|
||||
'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
{header && header}
|
||||
|
||||
@@ -13,9 +13,7 @@ import { Link } from '../primitives';
|
||||
export function Trademark(props: {
|
||||
context: GitBookSpaceContext;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { className, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -57,12 +55,10 @@ 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!',
|
||||
|
||||
className
|
||||
'page-no-toc:before:to-transparent!'
|
||||
)}
|
||||
>
|
||||
<TrademarkLink {...rest} />
|
||||
<TrademarkLink {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,9 +69,8 @@ export function Trademark(props: {
|
||||
export function TrademarkLink(props: {
|
||||
context: GitBookSpaceContext;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { context, placement, className } = props;
|
||||
const { context, placement } = props;
|
||||
const { space } = context;
|
||||
const language = getSpaceLanguage(context);
|
||||
|
||||
@@ -90,6 +85,9 @@ 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',
|
||||
|
||||
@@ -115,17 +113,29 @@ export function TrademarkLink(props: {
|
||||
'ring-tint-subtle',
|
||||
|
||||
'transition-colors',
|
||||
'pointer-events-auto',
|
||||
|
||||
className
|
||||
'pointer-events-auto'
|
||||
)}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement,
|
||||
}}
|
||||
>
|
||||
<Icon icon="gitbook" className={tcls('size-5', 'shrink-0')} />
|
||||
<span className={tcls('ml-3')}>{t(language, 'powered_by_gitbook')}</span>
|
||||
<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>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function encodeClientTableOfContents(
|
||||
result.push(
|
||||
removeUndefined({
|
||||
id: page.id,
|
||||
title: page.linkTitle || page.title,
|
||||
title: 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, type TooltipProps } from './Tooltip';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
export type ButtonProps = {
|
||||
href?: string;
|
||||
@@ -20,7 +20,6 @@ export type ButtonProps = {
|
||||
trailing?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
active?: boolean;
|
||||
tooltipProps?: TooltipProps;
|
||||
} & LinkInsightsProps &
|
||||
React.HTMLAttributes<HTMLElement>;
|
||||
|
||||
@@ -113,7 +112,6 @@ export const Button = React.forwardRef<
|
||||
active,
|
||||
trailing,
|
||||
disabled,
|
||||
tooltipProps,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
@@ -135,29 +133,18 @@ 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 = (
|
||||
<>
|
||||
{iconElement}
|
||||
{icon ? (
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
className={tcls('button-leading-icon size-[1em] shrink-0')}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
) : null}
|
||||
{iconOnly || (!children && !label) ? null : (
|
||||
<span className="button-content truncate">{children ?? label}</span>
|
||||
)}
|
||||
@@ -197,13 +184,9 @@ export const Button = React.forwardRef<
|
||||
|
||||
return (children || iconOnly) && label ? (
|
||||
<Tooltip
|
||||
rootProps={{
|
||||
open: disabled === true ? false : undefined,
|
||||
...tooltipProps?.rootProps,
|
||||
}}
|
||||
rootProps={{ open: disabled === true ? false : undefined }}
|
||||
label={label}
|
||||
triggerProps={{ disabled, ...tooltipProps?.triggerProps }}
|
||||
contentProps={{ ...tooltipProps?.contentProps }}
|
||||
triggerProps={{ disabled }}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
|
||||
@@ -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 in the same window without modifier keys (i.e. open in new tab).
|
||||
if (!isExternal && target !== '_blank' && !event.ctrlKey && !event.metaKey) {
|
||||
// Only trigger navigation context for internal links without modifier keys (i.e. open in new tab).
|
||||
if (!isExternal && !event.ctrlKey && !event.metaKey) {
|
||||
onNavigationClick(href);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,6 @@ 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;
|
||||
|
||||
@@ -55,16 +55,5 @@ 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.linkTitle || ancestor.title,
|
||||
label: 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.linkTitle || page.title;
|
||||
text = page.title;
|
||||
ancestors.push({
|
||||
label: page.linkTitle || page.title,
|
||||
label: 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.linkTitle || pageOrGroup.title;
|
||||
text = pageOrGroup.title;
|
||||
emoji = isCurrentPage ? undefined : page.emoji;
|
||||
icon = <PageIcon page={pageOrGroup} style={iconStyle} />;
|
||||
}
|
||||
|
||||
@@ -612,7 +612,6 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
}
|
||||
|
||||
switch (pathname) {
|
||||
case '~gitbook/embed':
|
||||
case '~gitbook/embed/assistant':
|
||||
case '~gitbook/icon':
|
||||
return { pathname };
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getContentTestURL } from './utils';
|
||||
|
||||
const EMBED_SCRIPT_URL = getContentTestURL(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/script.js'
|
||||
);
|
||||
|
||||
describe('embed script', () => {
|
||||
it('serves the embeddable script with permissive headers', async () => {
|
||||
const response = await fetch(EMBED_SCRIPT_URL, {
|
||||
headers: {
|
||||
Origin: 'https://example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/javascript');
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('*');
|
||||
expect(response.headers.get('cross-origin-resource-policy')).toBe('cross-origin');
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toContain('w.GitBook');
|
||||
});
|
||||
});
|
||||
@@ -37,30 +37,31 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
label: 'HTTP',
|
||||
syntax: 'http',
|
||||
generate: ({ method, url: { origin, path }, headers = {}, body }: CodeSampleInput) => {
|
||||
// Process URL and headers to use consistent placeholder format
|
||||
const processedPath = convertPathParametersToPlaceholders(path);
|
||||
const processedHeaders = processHeadersWithPlaceholders(headers);
|
||||
|
||||
if (body) {
|
||||
// if we had a body add a content length header
|
||||
const bodyContent = body ? stringifyOpenAPI(body) : '';
|
||||
// handle unicode chars with a text encoder
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const bodyString = BodyGenerators.getHTTPBody(body, headers);
|
||||
const bodyString = BodyGenerators.getHTTPBody(body, processedHeaders);
|
||||
|
||||
if (bodyString) {
|
||||
body = bodyString;
|
||||
}
|
||||
|
||||
headers = {
|
||||
...headers,
|
||||
'Content-Length': encoder.encode(bodyContent).length.toString(),
|
||||
};
|
||||
processedHeaders['Content-Length'] = encoder.encode(bodyContent).length.toString();
|
||||
}
|
||||
|
||||
if (!headers.hasOwnProperty('Accept')) {
|
||||
headers.Accept = '*/*';
|
||||
if (!processedHeaders.hasOwnProperty('Accept')) {
|
||||
processedHeaders.Accept = '*/*';
|
||||
}
|
||||
|
||||
const headerString = headers
|
||||
? `${Object.entries(headers)
|
||||
const headerString = processedHeaders
|
||||
? `${Object.entries(processedHeaders)
|
||||
.map(([key, value]) =>
|
||||
key.toLowerCase() !== 'host' ? `${key}: ${value}` : ''
|
||||
)
|
||||
@@ -69,8 +70,8 @@ export const codeSampleGenerators: CodeSampleGenerator[] = [
|
||||
|
||||
const bodyString = body ? `\n${body}` : '';
|
||||
|
||||
const httpRequest = `${method.toUpperCase()} ${decodeURI(path)} HTTP/1.1
|
||||
Host: ${origin.replaceAll(/https*:\/\//g, '')}
|
||||
const httpRequest = `${method.toUpperCase()} ${decodeURI(processedPath)} HTTP/1.1
|
||||
Host: ${origin.replace(/https*:\/\//g, '')}
|
||||
${headerString}${bodyString}`;
|
||||
|
||||
return httpRequest;
|
||||
@@ -87,15 +88,23 @@ ${headerString}${bodyString}`;
|
||||
lines.push(`--request ${method.toUpperCase()}`);
|
||||
}
|
||||
|
||||
lines.push(`--url '${origin}${path}'`);
|
||||
// Process URL and headers to use consistent placeholder format
|
||||
const processedUrl = convertPathParametersToPlaceholders(origin + path);
|
||||
const processedHeaders = processHeadersWithPlaceholders(headers);
|
||||
|
||||
lines.push(`--url '${processedUrl}'`);
|
||||
|
||||
if (body) {
|
||||
const bodyContent = BodyGenerators.getCurlBody(body, headers);
|
||||
const bodyContent = BodyGenerators.getCurlBody(body, processedHeaders);
|
||||
|
||||
if (bodyContent) {
|
||||
body = bodyContent.body;
|
||||
headers = bodyContent.headers;
|
||||
} else {
|
||||
headers = processedHeaders;
|
||||
}
|
||||
} else {
|
||||
headers = processedHeaders;
|
||||
}
|
||||
|
||||
if (headers && Object.keys(headers).length > 0) {
|
||||
@@ -122,18 +131,26 @@ ${headerString}${bodyString}`;
|
||||
generate: ({ method, url: { origin, path }, headers, body }) => {
|
||||
let code = '';
|
||||
|
||||
// Process URL and headers to use consistent placeholder format
|
||||
const processedUrl = convertPathParametersToPlaceholders(origin + path);
|
||||
const processedHeaders = processHeadersWithPlaceholders(headers);
|
||||
|
||||
if (body) {
|
||||
const lines = BodyGenerators.getJavaScriptBody(body, headers);
|
||||
const lines = BodyGenerators.getJavaScriptBody(body, processedHeaders);
|
||||
|
||||
if (lines) {
|
||||
// add the generated code to the top
|
||||
code += lines.code;
|
||||
body = lines.body;
|
||||
headers = lines.headers;
|
||||
} else {
|
||||
headers = processedHeaders;
|
||||
}
|
||||
} else {
|
||||
headers = processedHeaders;
|
||||
}
|
||||
|
||||
code += `const response = await fetch('${origin}${path}', {
|
||||
code += `const response = await fetch('${processedUrl}', {
|
||||
method: '${method.toUpperCase()}',\n`;
|
||||
|
||||
if (headers && Object.keys(headers).length > 0) {
|
||||
@@ -156,7 +173,19 @@ ${headerString}${bodyString}`;
|
||||
syntax: 'python',
|
||||
generate: ({ method, url: { origin, path }, headers, body }) => {
|
||||
const contentType = headers?.['Content-Type'];
|
||||
let code = `${isJSON(contentType) ? 'import json\n' : ''}import requests\n\n`;
|
||||
const needsJsonImport = body && isJSON(contentType) && typeof body === 'string';
|
||||
|
||||
let code = '';
|
||||
|
||||
// Import statements
|
||||
if (needsJsonImport) {
|
||||
code += 'import json\n';
|
||||
}
|
||||
code += 'import requests\n\n';
|
||||
|
||||
// Process headers and URL to use consistent placeholder format
|
||||
const processedUrl = convertPathParametersToPlaceholders(origin + path);
|
||||
const processedHeaders = processHeadersWithPlaceholders(headers);
|
||||
|
||||
if (body) {
|
||||
const lines = BodyGenerators.getPythonBody(body, headers);
|
||||
@@ -170,16 +199,25 @@ ${headerString}${bodyString}`;
|
||||
}
|
||||
|
||||
code += `response = requests.${method.toLowerCase()}(\n`;
|
||||
code += indent(`"${origin}${path}",\n`, 4);
|
||||
code += indent(`"${processedUrl}",\n`, 4);
|
||||
|
||||
if (headers && Object.keys(headers).length > 0) {
|
||||
code += indent(`headers=${stringifyOpenAPI(headers)},\n`, 4);
|
||||
if (processedHeaders && Object.keys(processedHeaders).length > 0) {
|
||||
code += indent(`headers={\n`, 4);
|
||||
Object.entries(processedHeaders).forEach(([key, value], index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
code += indent(`"${key}": "${value}"${isLast ? '' : ','}\n`, 8);
|
||||
});
|
||||
code += indent(`},\n`, 4);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
if (body === 'files') {
|
||||
code += indent(`files=${body}\n`, 4);
|
||||
} else if (isJSON(contentType)) {
|
||||
} else if (isJSON(contentType) && isPlainObject(body)) {
|
||||
// Use json parameter for dict objects
|
||||
code += indent(`json=${body}\n`, 4);
|
||||
} else if (isJSON(contentType) && needsJsonImport) {
|
||||
// Use data=json.dumps() for JSON strings
|
||||
code += indent(`data=json.dumps(${body})\n`, 4);
|
||||
} else {
|
||||
code += indent(`data=${body}\n`, 4);
|
||||
@@ -372,7 +410,8 @@ const BodyGenerators = {
|
||||
} else if (isYAML(contentType)) {
|
||||
code += `yamlBody = \"\"\"\n${indent(yaml.dump(body), 4)}\"\"\"\n\n`;
|
||||
body = 'yamlBody';
|
||||
} else {
|
||||
} else if (isJSON(contentType) && isPlainObject(body)) {
|
||||
// For dict objects, return as-is to use with json= parameter
|
||||
body = stringifyOpenAPI(
|
||||
body,
|
||||
(_key, value) => {
|
||||
@@ -389,9 +428,30 @@ const BodyGenerators = {
|
||||
},
|
||||
2
|
||||
)
|
||||
.replaceAll('"$$__TRUE__$$"', 'True')
|
||||
.replaceAll('"$$__FALSE__$$"', 'False')
|
||||
.replaceAll('"$$__NULL__$$"', 'None');
|
||||
.replace(/"\\$\\$__TRUE__\\$\\$"/g, 'True')
|
||||
.replace(/"\\$\\$__FALSE__\\$\\$"/g, 'False')
|
||||
.replace(/"\\$\\$__NULL__\\$\\$"/g, 'None');
|
||||
} else {
|
||||
// For everything else (including JSON strings)
|
||||
body = stringifyOpenAPI(
|
||||
body,
|
||||
(_key, value) => {
|
||||
switch (value) {
|
||||
case true:
|
||||
return '$$__TRUE__$$';
|
||||
case false:
|
||||
return '$$__FALSE__$$';
|
||||
case null:
|
||||
return '$$__NULL__$$';
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
},
|
||||
2
|
||||
)
|
||||
.replace(/"\\$\\$__TRUE__\\$\\$"/g, 'True')
|
||||
.replace(/"\\$\\$__FALSE__\\$\\$"/g, 'False')
|
||||
.replace(/"\\$\\$__NULL__\\$\\$"/g, 'None');
|
||||
}
|
||||
|
||||
return { body, code, headers };
|
||||
@@ -487,3 +547,44 @@ function buildHeredoc(lines: string[]): string {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts path parameters from {paramName} to YOUR_PARAM_NAME format
|
||||
*/
|
||||
function convertPathParametersToPlaceholders(urlPath: string): string {
|
||||
return urlPath.replace(/\{([^}]+)\}/g, (match, paramName) => {
|
||||
// Convert camelCase to UPPER_SNAKE_CASE
|
||||
const placeholder = paramName.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
|
||||
return `YOUR_${placeholder}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes headers to use consistent placeholder format
|
||||
*/
|
||||
function processHeadersWithPlaceholders(headers?: Record<string, string>): Record<string, string> {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
if (key === 'Authorization' && value.includes('Bearer')) {
|
||||
processedHeaders[key] = 'Bearer YOUR_API_TOKEN';
|
||||
} else if (key === 'Authorization' && value.includes('Basic')) {
|
||||
processedHeaders[key] = 'Basic YOUR_API_TOKEN';
|
||||
} else if (value.includes('YOUR_') || value.includes('TOKEN')) {
|
||||
// Already in correct format or generic token
|
||||
processedHeaders[key] = value.replace(
|
||||
/YOUR_SECRET_TOKEN|YOUR_TOKEN/g,
|
||||
'YOUR_API_TOKEN'
|
||||
);
|
||||
} else {
|
||||
// Regular headers - keep as-is
|
||||
processedHeaders[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return processedHeaders;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user