Merge branch 'main' into taran/resolve-page-meta-links-cr-or-space

This commit is contained in:
Taran Vohra
2025-12-05 11:54:57 +05:30
committed by GitHub
49 changed files with 1172 additions and 292 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/embed": minor
"gitbook": patch
---
Improve Docs Embed with separate Assistant and Docs tabs
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix hidden section not found
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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'
}
```
+1 -1
View File
@@ -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: [],
};
+15 -5
View File
@@ -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[];
+5 -4
View File
@@ -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
+47 -28
View File
@@ -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;
+12
View File
@@ -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');
}
@@ -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}
/>
);
}
@@ -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/`);
}
}
@@ -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}
/>
);
}
@@ -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;
+66 -4
View File
@@ -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);
}
+11
View File
@@ -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');
},
};
}
+4 -4
View File
@@ -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} />;
}
+1
View File
@@ -612,6 +612,7 @@ function encodePathInSiteContent(rawPathname: string): {
}
switch (pathname) {
case '~gitbook/embed':
case '~gitbook/embed/assistant':
case '~gitbook/icon':
return { pathname };