Compare commits

..

5 Commits

Author SHA1 Message Date
Zeno Kapitein 210cdcaa68 Fix 2026-04-09 09:52:05 +02:00
Zeno Kapitein d4247d25f1 Typecheck 2026-04-08 18:47:56 +02:00
Zeno Kapitein 33e87a0183 Simplify 2026-04-08 18:28:46 +02:00
Zeno Kapitein 75a217d5c9 New implementation 2026-04-08 18:15:38 +02:00
Zeno Kapitein fed63b4675 Docs Embed: Better support light/dark mode overrides 2026-04-08 14:23:38 +02:00
216 changed files with 3962 additions and 8315 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Skip AI questions when it matches the example provided to agents / crawlers.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Improve Assistant form controls
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Support variant selector in hidden section
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/icons": patch
---
Icon clipping fix
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Improve AI agent detection and readability by outputting 200 for page not found.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix embeddable search links
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Pass visitor session to Ask AI.
@@ -64,7 +64,6 @@ runs:
run: bun run turbo build:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }}
shell: bash
- name: Upload the DO worker
@@ -19,9 +19,6 @@ inputs:
environment:
description: 'Environment to deploy to'
required: true
headSha:
description: 'Git ref to deploy, used for the deploymentId'
required: false
outputs:
deployment-url:
description: "Deployment URL"
@@ -62,17 +59,6 @@ runs:
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Inject build env vars
if: ${{ inputs.environment == 'preview' }}
shell: bash
run: |
HEAD_SHA=$(git rev-parse HEAD)
echo "resolved HEAD_SHA: $HEAD_SHA"
echo "GITBOOK_HEAD_SHA=$HEAD_SHA" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_RUNTIME=vercel" >> .vercel/.env.${{ inputs.environment }}.local
echo "GITBOOK_BLOCK_SEARCH_INDEXATION=true" >> .vercel/.env.${{ inputs.environment }}.local
echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---"
cat .vercel/.env.${{ inputs.environment }}.local
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
@@ -80,7 +66,6 @@ runs:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
GITBOOK_HEAD_SHA: ${{ inputs.headSha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
-1
View File
@@ -40,7 +40,6 @@ jobs:
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
headSha: ${{ github.event.pull_request.head.sha || github.sha }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
+3 -3
View File
@@ -26,10 +26,9 @@ jobs:
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: |
corepack enable npm
corepack install -g npm@latest
run: npm install -g npm@latest
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -46,3 +45,4 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
-1
View File
@@ -23,7 +23,6 @@
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"gitbook/tsconfig.json",
"**/*.css"
]
},
+1519 -292
View File
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.6",
"turbo": "^2.9.2",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
@@ -17,9 +17,8 @@
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
"esbuild": "0.24.2",
"axios": "1.8.4"
},
"private": true,
"scripts": {
@@ -43,7 +42,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.179.0",
"@gitbook/api": "0.175.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -55,13 +54,11 @@
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1",
"vercel": "^50.26.1"
"usehooks-ts": "^3.1.1"
}
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.3": "patches/next@16.2.3.patch"
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
}
}
-10
View File
@@ -1,15 +1,5 @@
# @gitbook/embed
## 0.5.0
### Minor Changes
- 88c38fa: Add Search tab to Docs Embed, refactor search
### Patch Changes
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
## 0.4.0
### Minor Changes
+47 -30
View File
@@ -2,9 +2,8 @@
Embed your GitBook docs in your product or website.
The Docs Embed can contain three tabs:
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
- **Search**: A search-focused surface for quickly finding pages and asking scoped questions
- **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.
@@ -41,7 +40,7 @@ GitBook('configure', {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -73,7 +72,6 @@ const gitbook = createGitBook({
// Create an iframe and get its URL
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({
colorScheme: 'dark', // Optional: force the embed to render in dark mode
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
@@ -94,7 +92,7 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -124,12 +122,11 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame
colorScheme="dark"
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'search', 'docs']}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
@@ -153,7 +150,7 @@ import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
// ...
}
```
@@ -181,7 +178,7 @@ function MyComponent() {
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
@@ -198,7 +195,7 @@ function MyComponent() {
**Client Factory:**
- `createGitBook(options: { siteURL: string })` → `GitBookClient`
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` → `string`
- `client.getFrameURL(options?: { visitor?: {...} })` → `string`
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
**Frame Client:**
@@ -231,10 +228,10 @@ Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'search' | 'docs')[]`
- **Type**: `('assistant' | 'docs')[]`
```javascript
tabs: ['assistant', 'search', 'docs']
tabs: ['assistant', 'docs']
```
### `closeButton`
@@ -288,6 +285,44 @@ Display GitBook branding in the embed. Defaults to true.
trademark: true
```
### Theming and `color-scheme` (CSS-first)
The embed supports both site-controlled theming and CSS-driven theming.
Precedence (highest → lowest):
- **Site mode / forced theme**: if the GitBook site does not support multiple themes, the embed is forced to the site’s default theme.
- **Visitor preference**: if the site supports multiple themes and the visitor has previously selected a theme on the site, that preference is remembered.
- **Browser/OS default**: otherwise the embed follows the browser/OS preference (`prefers-color-scheme`).
You can also drive the embed theme from CSS by setting `color-scheme` on the iframe element (or a parent it inherits from). When the iframe resolves to an explicit `color-scheme: light` or `color-scheme: dark`, that value is propagated into the embedded content so it renders consistently.
Standalone widget example:
```css
/* Force the GitBook widget iframe to render in dark mode */
#gitbook-widget-iframe {
color-scheme: dark;
}
```
### `colorScheme`
Available in: Standalone script, NPM package, React components
Force the embed to render in a specific color scheme.
- **Type**: `'light' | 'dark'`
- **Default**: `undefined` (follow site/visitor preference/system)
```javascript
GitBook('configure', {
colorScheme: 'dark'
});
```
To clear an override, omit `colorScheme` (or set it to `undefined` in JS) in a subsequent `configure` call.
### `actions`
Available in: Standalone script, NPM package, React components
@@ -460,24 +495,6 @@ visitor: {
}
```
### `colorScheme`
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
**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({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```javascript
colorScheme: 'dark'
```
### `button`
Available in: Standalone script only
+2 -2
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.5.0",
"version": "0.4.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -32,7 +32,7 @@
"scripts": {
"build": "bun run build-lib && bun run build-standalone",
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"build-standalone": "bun run ./scripts/build-standalone.ts",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
@@ -0,0 +1,36 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { Features, transform } from 'lightningcss';
/**
* Build the standalone embed script.
* Bun's default CSS transpiler (which is a port of LightningCSS) strips out the native light-dark() function in favour of a polyfill.
* Light-dark() is widely supported now, and the polyfill requires you to set a data attribute on the element instead of relying on plain CSS.
* This script's purpose is to pass a feature flag to the CSS transpiler to keep the native light-dark() behavior.
*/
await new Promise<void>((resolve, reject) => {
const child = spawn(
'bun',
['build', 'src/standalone/index.ts', '--bundle', '--minify', '--outdir=standalone'],
{ stdio: 'inherit' }
);
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`bun build failed with exit code ${code ?? 'unknown'}`));
});
});
const sourceCSS = await readFile('src/standalone/style.css');
const transformedCSS = transform({
filename: 'src/standalone/style.css',
code: sourceCSS,
minify: true,
// Keep native light-dark() behavior scoped to element color-scheme.
exclude: Features.LightDark,
});
await writeFile('standalone/index.css', transformedCSS.code);
@@ -35,17 +35,4 @@ describe('createGitBook.getFrameURL', () => {
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
it('adds an explicit color scheme override when requested', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
colorScheme: 'dark',
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('theme')).toBe('dark');
});
});
@@ -8,12 +8,6 @@ export type CreateGitBookOptions = {
};
export type GetFrameURLOptions = {
/**
* Override the color scheme used by the embedded docs.
* When omitted, the embed follows the iframe's CSS `color-scheme`.
*/
colorScheme?: 'light' | 'dark';
/**
* Authentication to use for the frame.
*/
@@ -48,10 +42,6 @@ export function createGitBook(options: CreateGitBookOptions) {
const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.colorScheme) {
url.searchParams.set('theme', frameOptions.colorScheme);
}
if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token);
}
@@ -64,7 +64,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
+6 -1
View File
@@ -45,7 +45,7 @@ export type GitBookEmbeddableActionDefinition = {
*/
export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs' | 'search')[];
tabs: ('assistant' | 'docs')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
@@ -83,6 +83,11 @@ export type GitBookEmbeddableConfiguration = {
* Display a close button inside the assistant.
*/
closeButton?: boolean;
/**
* Force the embed to render in a specific color-scheme.
*/
colorScheme?: 'light' | 'dark';
};
/**
+2 -7
View File
@@ -19,13 +19,12 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) {
const {
className,
colorScheme,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'search', 'docs'],
tabs = ['assistant', 'docs'],
trademark = true,
closeButton = false,
assistantName,
@@ -35,10 +34,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(
() => gitbook.getFrameURL({ visitor, colorScheme }),
[gitbook, visitor, colorScheme]
);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
useEffect(() => {
if (frameRef.current) {
@@ -77,7 +73,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
width="100%"
height="100%"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+46 -5
View File
@@ -55,6 +55,7 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let lastPushedColorScheme: 'light' | 'dark' | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
@@ -64,7 +65,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
trademark: true,
};
@@ -85,6 +86,43 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
/** Resolved `color-scheme` from the iframe element (incl. inheritance from `#gitbook-widget-window`). */
function colorSchemeFromIframe(): 'light' | 'dark' | undefined {
if (!widgetIframe) return undefined;
const v = getComputedStyle(widgetIframe).colorScheme.trim().toLowerCase();
return v === 'dark' || v === 'light' ? v : undefined;
}
function pushColorSchemeToFrame() {
if (!_frame) return;
const desired = frameConfiguration.colorScheme ?? colorSchemeFromIframe();
if (desired === lastPushedColorScheme) return;
lastPushedColorScheme = desired;
_frame.configure({
...frameConfiguration,
colorScheme: desired,
});
}
/** Re-push when the host page or widget chrome changes theme (class/style) or OS preference changes. */
function installHostThemeBridge() {
const onChange = () => pushColorSchemeToFrame();
window.matchMedia?.('(prefers-color-scheme: dark)')?.addEventListener?.('change', onChange);
if (typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(onChange);
const opts: MutationObserverInit = {
attributes: true,
attributeFilter: ['class', 'style'],
};
observer.observe(document.documentElement, opts);
// Safari: `color-scheme` on `#gitbook-widget-window` does not always surface on `<html>`.
observer.observe(widgetWindow, opts);
}
installHostThemeBridge();
function getClient() {
if (!_client) {
throw new Error(
@@ -99,11 +137,9 @@ function getIframe() {
const client = getClient();
widgetIframe?.remove();
lastPushedColorScheme = undefined;
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
@@ -114,6 +150,8 @@ function getIframe() {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
pushColorSchemeToFrame();
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -191,9 +229,12 @@ const GitBook = (...args: StandaloneCalls) => {
}
}
getIframe().frame.configure({
const { frame } = getIframe();
// Always propagate configuration updates, even when color-scheme doesn't change.
frame.configure({
...frameConfiguration,
});
pushColorSchemeToFrame();
break;
}
case 'clearChat':
+14 -15
View File
@@ -8,14 +8,6 @@
--gitbook-widget-radius: .5rem;
--gitbook-widget-text-size: 1rem;
--gitbook-widget-text-color: #656973;
--gitbook-widget-border-color: #e5e5e5;
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
--gitbook-widget-background-solid: #FFFFFF;
--gitbook-widget-background-solid-hover: #FBFBFB;
--gitbook-widget-icon-size: 1.25rem;
--gitbook-widget-window-width: 28rem; /* 448px */
@@ -29,13 +21,20 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
#gitbook-widget-button,
#gitbook-widget-window {
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
--gitbook-widget-background-solid: light-dark(#FFFFFF, #f0f0f0);
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #f0f0f0);
&[data-color-scheme=light] {
color-scheme: light;
}
&[data-color-scheme=dark] {
color-scheme: dark;
}
}
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/expr
## 1.2.5
### Patch Changes
- 8242f18: Extend gitbook/expr std lib with some additional methods
## 1.2.4
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.5",
"version": "1.2.4",
"type": "module",
"exports": {
".": {
@@ -25,7 +25,6 @@ describe('autocomplete', () => {
description: 'An array of string',
items: SymbolString(),
});
const visitorClaimsKeyPropStringSymbol = SymbolString({ name: 'key' });
const symbols = {
visitor: SymbolObject({
name: 'visitor',
@@ -34,7 +33,7 @@ describe('autocomplete', () => {
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: visitorClaimsKeyPropStringSymbol,
key: SymbolString({ name: 'key' }),
flags: SymbolObject({
name: 'flags',
properties: {
@@ -169,12 +168,8 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
},
},
@@ -232,12 +227,8 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.role.length',
'visitor.claims.role.at',
'visitor.claims.role.startsWith',
'visitor.claims.role.endsWith',
'visitor.claims.role.includes',
'visitor.claims.role.toLowerCase',
'visitor.claims.role.toUpperCase',
'visitor.claims.role.trim',
],
},
},
@@ -255,47 +246,13 @@ describe('autocomplete', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
},
},
],
},
{
expressionWithCursor: 'visitor.claims.key.<cur>',
expectedSuggestions: [
{
type: 'symbol',
symbol: {
definition: SymbolNumber({
name: 'length',
description:
'The length data property of a String value contains the length of the string in UTF-16 code units.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
}),
ref: 'visitor.claims.key.length',
parentRef: 'visitor.claims.key',
childrenRefs: [],
},
},
...visitorClaimsKeyPropStringSymbol.methods.map<AutocompleteSymbolSuggestion>(
(method) => ({
type: 'symbol',
symbol: {
definition: method,
ref: `visitor.claims.key.${method.name}`,
parentRef: 'visitor.claims.key',
childrenRefs: [],
},
})
),
],
},
{
expressionWithCursor: 'visitor.claims.h<cur>',
expectedSuggestions: [
@@ -54,54 +54,12 @@ describe('ExpressionRuntime', () => {
inputs: { reviews: [{ status: 'approved' }, { status: 'approved' }] },
expectedResult: true,
},
{
scenario: 'array includes',
condition: 'reviews.includes("approved")',
inputs: { reviews: ['pending', 'approved'] },
expectedResult: true,
},
{
scenario: 'array map',
condition: '[1, 2, 3].map(n => n * x)',
inputs: { x: 2 },
expectedResult: [2, 4, 6],
},
{
scenario: 'string startsWith',
condition: 'user.role.startsWith("ad")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string endsWith',
condition: 'user.role.endsWith("min")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string includes',
condition: 'user.role.includes("dm")',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string toLowerCase',
condition: 'user.role.toLowerCase() === "admin"',
inputs: { user: { role: 'ADMIN' } },
expectedResult: true,
},
{
scenario: 'string toUpperCase',
condition: 'user.role.toUpperCase() === "ADMIN"',
inputs: { user: { role: 'admin' } },
expectedResult: true,
},
{
scenario: 'string trim',
condition: 'user.role.trim() === "admin"',
inputs: { user: { role: ' admin ' } },
expectedResult: true,
},
])(
'should properly evaluate/safeEvaluate a valid conditional expression: $scenario',
({ condition, inputs, expectedResult }) => {
@@ -255,12 +255,8 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.key.length',
'visitor.claims.key.at',
'visitor.claims.key.startsWith',
'visitor.claims.key.endsWith',
'visitor.claims.key.includes',
'visitor.claims.key.toLowerCase',
'visitor.claims.key.toUpperCase',
'visitor.claims.key.trim',
],
});
@@ -305,12 +301,8 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.flags.FLAG1.length',
'visitor.claims.flags.FLAG1.at',
'visitor.claims.flags.FLAG1.startsWith',
'visitor.claims.flags.FLAG1.endsWith',
'visitor.claims.flags.FLAG1.includes',
'visitor.claims.flags.FLAG1.toLowerCase',
'visitor.claims.flags.FLAG1.toUpperCase',
'visitor.claims.flags.FLAG1.trim',
],
});
@@ -326,12 +318,8 @@ describe('ExpressionRuntime', () => {
childrenRefs: [
'visitor.claims.flags.FLAG2.length',
'visitor.claims.flags.FLAG2.at',
'visitor.claims.flags.FLAG2.startsWith',
'visitor.claims.flags.FLAG2.endsWith',
'visitor.claims.flags.FLAG2.includes',
'visitor.claims.flags.FLAG2.toLowerCase',
'visitor.claims.flags.FLAG2.toUpperCase',
'visitor.claims.flags.FLAG2.trim',
],
});
-58
View File
@@ -156,31 +156,6 @@ const StandardLibrary: Partial<
members: [SymbolString(), SymbolUndefined()],
}),
}),
SymbolFunction({
name: 'startsWith',
description: `Returns true if the given characters are found at the beginning of the string, including when searchString
is an empty string. Otherwise returns false.`,
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
args: [
SymbolString({
name: 'searchString',
description: `The characters to be searched for at the start of this string. Cannot be a regex.
All values that are not regexes are coerced to strings, so omitting it or passing undefined causes startsWith() to search for
the string "undefined", which is rarely what you want.`,
}),
OptionalFunctionArg(
SymbolNumber({
name: 'position',
description: `The start position at which searchString is expected to be found
(the index of searchString's first character). Defaults to 0.`,
})
),
],
returns: SymbolBoolean({
description: `true if the given characters are found at the beginning of the string, including when searchString is an empty string;
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'endsWith',
description: `Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding
@@ -230,39 +205,6 @@ const StandardLibrary: Partial<
otherwise, false.`,
}),
}),
SymbolFunction({
name: 'toLowerCase',
description:
'Returns the value of the string converted to lower case. toLowerCase() does not affect the value of the string str itself.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
args: [],
returns: SymbolString({
description:
'A new string representing the calling string converted to lower case.',
}),
}),
SymbolFunction({
name: 'toUpperCase',
description:
'Returns the value of the string converted to uppercase. toUpperCase() does not affect the value of the string str itself.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
args: [],
returns: SymbolString({
description:
'A new string representing the calling string converted to upper case.',
}),
}),
SymbolFunction({
name: 'trim',
description:
'Returns new string representing str stripped of whitespace from both its beginning and end.',
link: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim',
args: [],
returns: SymbolString({
description:
'A new string representing str stripped of whitespace from both its beginning and end.',
}),
}),
],
},
[SymbolType.Array]: (arraySymbolDef: ArraySymbolDef) => ({
-38
View File
@@ -1,43 +1,5 @@
# gitbook
## 0.25.1
### Patch Changes
- aa1fc24: Add support for sticky first column in tables
## 0.25.0
### Minor Changes
- 88c38fa: Add Search tab to Docs Embed, refactor search
- 709964f: Rework page layout
### Patch Changes
- 789c2fa: Adjust empty TOC logic and layout
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
- 7fd381c: Hide empty sidebar on OpenAPI pages
- 723e579: Fix rendering multi-expandable block in a column
- 68c842b: Translations dropdown should show language spaces that best matches the current space title
- Updated dependencies [8d2a95b]
- Updated dependencies [88c38fa]
- @gitbook/embed@0.5.0
## 0.24.0
### Minor Changes
- 50653ab: Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
- 50653ab: Improve AI agent detection and readability by outputting 200 for page not found.
### Patch Changes
- 126d4b7: Improve Assistant form controls
- Updated dependencies [8242f18]
- @gitbook/expr@1.2.5
- @gitbook/react-openapi@1.5.12
## 0.23.2
### Patch Changes
+10 -12
View File
@@ -110,12 +110,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.midas.app',
tests: [{ name: 'Home', url: '/' }],
},
// Disabling for now, one of the image is making the tests timeout
// {
// name: 'docs.keeper.io',
// contentBaseURL: 'https://docs.keeper.io',
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
// },
{
name: 'docs.keeper.io',
contentBaseURL: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'adiblar.gitbook.io',
contentBaseURL: 'https://adiblar.gitbook.io',
@@ -223,12 +222,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.soniclabs.com',
tests: [{ name: 'Home', url: '/' }],
},
// This one redirects to binance now
// {
// name: 'docs.meshchain.ai',
// contentBaseURL: 'https://docs.meshchain.ai',
// tests: [{ name: 'Home', url: '/' }],
// },
{
name: 'docs.meshchain.ai',
contentBaseURL: 'https://docs.meshchain.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.thousandeyes.com',
contentBaseURL: 'https://docs.thousandeyes.com',
+3 -4
View File
@@ -82,6 +82,8 @@ const searchTestCases: Test[] = [
});
const pageResults = await page.getByTestId('search-page-result').all();
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
},
},
@@ -779,9 +781,7 @@ const testCases: TestsCase[] = [
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
// Trademark exists by default
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
1
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
// Go to another page with the customization query to disable the trademark
const pageBlocks = new URL(page.url());
@@ -874,7 +874,6 @@ const testCases: TestsCase[] = [
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
fullPage: true,
tests: [
{
name: 'Text',
+2 -6
View File
@@ -75,10 +75,6 @@ export type TestsCase = {
skip?: boolean;
tests: Array<Test>;
contentBaseURL?: string;
/**
* Whether screenshots in this test case should capture the full scrollable page by default.
*/
fullPage?: boolean;
};
export const allLocales: CustomizationLocale[] = [
@@ -244,9 +240,9 @@ export function runTestCases(testCases: TestsCase[]) {
.intercom-lightweight-app {
display: none !important;
}
`,
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
if (screenshotOptions?.waitForTOCScrolling !== false) {
+2 -22
View File
@@ -1,27 +1,10 @@
// @ts-check
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
let deploymentId =
process.env.GITBOOK_RUNTIME === 'cloudflare'
? undefined
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
const { VERCEL_TARGET_ENV } = process.env;
// Because preview, staging and prod shares the same SHA, the deployment will fail if we don't prefix it with the environment name.
if (VERCEL_TARGET_ENV === 'preview') {
deploymentId = `t-${deploymentId}`;
} else if (VERCEL_TARGET_ENV === 'staging') {
deploymentId = `s-${deploymentId}`;
} else if (VERCEL_TARGET_ENV === 'production') {
deploymentId = `p-${deploymentId}`;
}
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -35,12 +18,10 @@ const nextConfig = {
// Since content is fully static, we don't want to fetch on hover again
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
},
env: {
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
@@ -59,7 +40,6 @@ const nextConfig = {
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
@@ -2,7 +2,7 @@
"main": "default.js",
"name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2026-04-02",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
@@ -1,7 +1,7 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2026-04-02",
"compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
+16 -22
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.25.1",
"version": "0.23.2",
"private": true,
"dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
@@ -19,8 +19,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.14.4",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
@@ -28,11 +28,10 @@
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tusbar/cache-control": "^1.0.2",
"@vercel/agent-readability": "^0.4.0",
"@vercel/agent-readability": "^0.2.1",
"ai": "^4.2.2",
"assert-never": "catalog:",
"bidc": "catalog:",
@@ -40,23 +39,22 @@
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"flexsearch": "^0.8.212",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"leven": "^4.1.0",
"mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17",
"mermaid": "^11.14.0",
"mermaid": "^11.12.3",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "^16.2.3",
"leven": "^4.1.0",
"next": "15.4.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -64,8 +62,8 @@
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"quick-lru": "^7.0.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -100,8 +98,8 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
@@ -114,17 +112,17 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.79.0",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
"build": "next build --webpack",
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
"dev": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
"build:cloudflare": "opennextjs-cloudflare build",
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
@@ -140,9 +138,5 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
@@ -1,36 +0,0 @@
import {
type RouteLayoutParams,
getDynamicSiteContext,
getSiteURLDataFromParams,
} from '@/app/utils';
import { getVisitorAuthBasePath } from '@/lib/data';
import { getVisitorAuthCookieName } from '@/lib/visitors';
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
/**
* Clear the site-scoped auth session cookies and redirect to the site root.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const resolvedParams = await params;
const [{ context }, siteURLData, cookieStore] = await Promise.all([
getDynamicSiteContext(resolvedParams),
getSiteURLDataFromParams(resolvedParams),
cookies(),
]);
cookieStore.delete(
getVisitorAuthCookieName(
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
)
);
// TODO: Redirect to the site root for now. Once the API supports it,
// optionally redirect to a logoutURL (e.g when needing to logout from upstream auth too)
// when defined in visitor auth settings.
return NextResponse.redirect(context.linker.toAbsoluteURL(context.linker.toPathInSite('')));
}
@@ -5,7 +5,6 @@ import {
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
@@ -19,14 +18,12 @@ export default async function RootLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
const forcedTheme = await getThemeFromMiddleware();
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
forcedTheme={forcedTheme}
>
{children}
</EmbeddableRootLayout>
@@ -1,16 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableSearchPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
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);
return <EmbeddableSearchPage context={context} />;
}
@@ -20,10 +20,6 @@ export async function handleMcpRequest(
const { context } = await getDynamicSiteContext(params);
const { dataFetcher, linker, site } = context;
if (!context.customization.pageActions.mcp) {
return new Response('Not Found', { status: 404 });
}
// Next.js request.url is the original URL and not the rewritten one from the middleware
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
requestURL.search = rawRequest.nextUrl.search;
@@ -4,8 +4,8 @@ import type {
OrderedComputedResult,
SearchSiteContentRequest,
} from '@/components/Search/search-types';
import type { GitBookBaseContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { getServerActionBaseContext } from '@/lib/server-actions';
@@ -16,41 +16,54 @@ import type {
SiteSection,
SiteSectionGroup,
SiteSpace,
Space,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { type NextRequest, NextResponse } from 'next/server';
type SearchResultGroup = {
score: number;
items: OrderedComputedResult[];
};
export async function POST(request: NextRequest) {
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
const [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
getSiteURLDataFromMiddleware(),
]);
const body = (await request.json()) as SearchSiteContentRequest;
const { query, scope } = body;
if (query.length <= 1) {
return NextResponse.json([]);
}
const [searchResults, { structure }] = await Promise.all([
throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
query,
scope,
})
),
throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: siteURLData.organization,
siteId: siteURLData.site,
siteShareKey: siteURLData.shareKey,
})
),
(async () => {
const result = await throwIfDataError(
context.dataFetcher.searchSiteContent({
organizationId: organization,
siteId: site,
query,
scope,
})
);
return result;
})(),
(async () => {
const result = await throwIfDataError(
context.dataFetcher.getPublishedContentSite({
organizationId: organization,
siteId: site,
siteShareKey: shareKey,
})
);
return result;
})(),
]);
const results = searchResults
.flatMap((resultItem) => {
.flatMap((resultItem): SearchResultGroup[] => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
@@ -60,25 +73,31 @@ export async function POST(request: NextRequest) {
href: resultItem.url,
score: resultItem.score,
};
return [{ score: resultItem.score, items: [result] }];
return [
{
score: resultItem.score,
items: [result],
},
];
}
const found = findSiteSpaceBy(
structure,
(siteSpace) => siteSpace.space.id === resultItem.id
);
const siteSection = found?.siteSection;
const siteSectionGroup = found?.siteSectionGroup;
return resultItem.pages.map((pageItem) => ({
score: pageItem.score,
items: transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
items: transformSitePageResult(context, {
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
}),
}));
})
@@ -88,117 +107,72 @@ export async function POST(request: NextRequest) {
return NextResponse.json(results);
}
function transformSitePageResult(args: {
asEmbeddable: boolean;
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
siteSpace?: SiteSpace;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup | null;
}): OrderedComputedResult[] {
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
args;
function transformSitePageResult(
context: GitBookBaseContext,
args: {
pageItem: SearchPageResult;
spaceItem: SearchSpaceResult;
space?: Space;
siteSpace?: SiteSpace;
spaceURL?: string;
siteSection?: SiteSection;
siteSectionGroup?: SiteSectionGroup;
}
): OrderedComputedResult[] {
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
const { linker } = context;
const currentLanguage = siteSpace?.space.language;
const spaceURL = siteSpace?.urls.published;
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
if (siteSectionGroup) {
breadcrumbs.push({
icon: siteSectionGroup.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
});
}
if (siteSection) {
breadcrumbs.push({
icon: siteSection.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
});
}
if (
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection.siteSpaces?.filter(
(candidate) => candidate.space.language === space.space.language
).length > 1
).length ?? 0) > 1 &&
siteSpace
) {
breadcrumbs.push({
label: getLocalizedTitle(siteSpace, currentLanguage),
});
}
breadcrumbs.push(
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
}))
);
const pageHref = !spaceURL
? linker.toPathInSpace(pageItem.path)
: asEmbeddable
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
const page: ComputedPageResult = {
type: 'page',
id: `${spaceItem.id}/${pageItem.id}`,
title: pageItem.title,
href: pageHref,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
pageId: pageItem.id,
spaceId: spaceItem.id,
score: pageItem.score,
breadcrumbs,
breadcrumbs: [
siteSectionGroup && {
icon: siteSectionGroup?.icon as IconName,
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
},
siteSection && {
icon: siteSection?.icon as IconName,
label: getLocalizedTitle(siteSection, currentLanguage),
},
(siteSection?.siteSpaces?.filter(
(space) =>
siteSection?.siteSpaces?.filter(
(s) => s.space.language === space.space.language
).length > 1
).length ?? 0) > 1 && siteSpace
? {
label: getLocalizedTitle(siteSpace, currentLanguage),
}
: undefined,
...pageItem.ancestors.map((ancestor) => ({
label: ancestor.title,
})),
].filter((item) => item !== undefined),
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(pageItem.path);
.map<ComputedSectionResult>((section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
})) ?? [];
if (spaceURL) {
if (asEmbeddable) {
sectionHref = toEmbeddableLinkForPublishedContent(
linker,
spaceURL,
section.path
);
} else {
sectionHref = linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, section.path)
);
}
}
return {
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: sectionHref,
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: section.score,
};
}) ?? [];
// Find the best-scoring section to use as a body preview on the page result.
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
(best, section) => (!best || section.score > best.score ? section : best),
undefined
);
if (bestSection) {
page.bestSection = {
href: bestSection.href,
title: bestSection.title,
body: bestSection.body,
score: bestSection.score,
};
}
return [page];
return [page, ...pageSections];
}
@@ -11,5 +11,5 @@ export async function GET(
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -125,7 +125,7 @@ export async function GET(
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'search', 'docs'],
tabs: ['assistant', 'docs'],
closeButton: useCustomTrigger
});
@@ -1,16 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableSearchPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
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 getEmbeddableStaticContext(params);
return <EmbeddableSearchPage context={context} />;
}
@@ -1,16 +0,0 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
) {
const { question: encodedQuestion } = await params;
const { context } = await getStaticSiteContext(await params);
const question = decodeURIComponent(encodedQuestion);
return serveAskMarkdown(context, question);
}
@@ -1,183 +0,0 @@
import {
type RevisionPage,
type RevisionPageDocument,
type RevisionPageGroup,
SiteVisibility,
} from '@gitbook/api';
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { throwIfDataError } from '@/lib/data';
import { isPageIndexable } from '@/lib/seo';
import {
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLocalizedTitle,
listAllSiteSpaces,
} from '@/lib/sites';
interface Breadcrumb {
label: string;
icon?: string;
emoji?: string;
}
interface RawIndexPage {
id: string;
title: string;
pathname: string;
siteSpaceId: string;
lang?: string;
icon?: string;
emoji?: string;
description?: string;
breadcrumbs?: Breadcrumb[];
}
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
interface IndexPageEntry {
page: RevisionPageDocument;
ancestors: AncestorPage[];
}
/**
* Walk the page tree and return all indexable document pages together with
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
*/
function getIndexablePagesWithAncestors(
rootPages: RevisionPage[],
ancestors: AncestorPage[] = []
): IndexPageEntry[] {
const results: IndexPageEntry[] = [];
for (const page of rootPages) {
if (page.type === 'link' || page.type === 'computed') continue;
if (page.hidden || !isPageIndexable([], page)) continue;
if (page.type === 'document') {
results.push({ page, ancestors });
// Recurse into children with this document as an ancestor
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
} else if (page.type === 'group') {
// Groups themselves are not documents — push them only as ancestors
if (page.pages?.length) {
results.push(
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
...ancestors,
page,
])
);
}
}
}
return results;
}
export const revalidate = 86400; // 1 day in seconds
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
const { dataFetcher, linker, structure } = context;
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
const revisions = await Promise.all(
visibleSpaces.map((ss) =>
throwIfDataError(
dataFetcher.getRevision({
spaceId: ss.space.id,
revisionId: ss.space.revision,
})
)
)
);
const seen = new Set<string>();
const pages: RawIndexPage[] = [];
for (let i = 0; i < visibleSpaces.length; i++) {
const siteSpace = visibleSpaces[i]!;
const revision = revisions[i]!;
const forkedLinker = linker.withOtherSiteSpace({
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
});
const lang = siteSpace.space.language ?? undefined;
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
const cacheKey = `${siteSpace.id}:${page.id}`;
if (seen.has(cacheKey)) continue;
seen.add(cacheKey);
const breadcrumbs: Breadcrumb[] = [
siteSectionGroup
? {
label: getLocalizedTitle(siteSectionGroup, lang),
icon: siteSectionGroup.icon ?? undefined,
}
: undefined,
siteSection
? {
label: getLocalizedTitle(siteSection, lang),
icon: siteSection.icon ?? undefined,
}
: undefined,
...ancestors.map((a) => ({
label: a.title,
icon: a.icon ?? undefined,
emoji: a.emoji ?? undefined,
})),
].filter((c) => c !== undefined);
pages.push({
id: page.id,
title: page.title,
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
siteSpaceId: siteSpace.id,
lang,
icon: page.icon ?? undefined,
emoji: page.emoji ?? undefined,
description: page.description ?? undefined,
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
});
}
}
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
const shouldCacheOnClient =
context.site.visibility === SiteVisibility.Public ||
context.site.visibility === SiteVisibility.Unlisted;
return new Response(
JSON.stringify({
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
version: 1,
pages,
}),
{
headers: {
'Content-Type': 'application/json',
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
'Cache-Control': shouldCacheOnClient
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
: 'no-store',
},
}
);
}
+1 -1
View File
@@ -119,7 +119,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/**
* Get the decoded site data from the params.
*/
export function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
try {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
@@ -26,7 +26,7 @@ export async function POST(req: NextRequest) {
body.tags.forEach((tag) => {
logger.log(`Revalidating tag: ${tag}`);
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
revalidateTag(tag);
});
return NextResponse.json({
@@ -1,7 +1,6 @@
import { CollapsibleContent } from '@/components/primitives';
import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
import type { AIMessage } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary';
import type { RenderAIMessageOptions } from './types';
@@ -18,41 +17,31 @@ export function AIMessageView(
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return message.steps.length > 0 ? (
<div className="flex flex-col space-y-2">
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
const hasContent = Boolean(step.content && step.content.nodes.length > 0);
const Tag =
step.phase === AIMessageStepPhase.Commentary ? CollapsibleContent : 'div';
return (
<Tag
<div
key={index}
className={tcls(
'flex flex-col space-y-2 border-tint-subtle',
hasContent ? 'has-content' : ''
'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
{step.content ? (
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style={tcls(
'ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden',
step.phase === AIMessageStepPhase.Commentary
? 'text-tint group-data-[disabled]/collapsible:text-inherit'
: ''
)}
/>
) : null}
<DocumentView
document={step.content}
context={{
mode: 'default',
contentContext: context,
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden"
/>
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null}
</Tag>
</div>
);
})}
</div>
@@ -39,7 +39,9 @@ export async function streamRenderAIMessage(
if (message.steps[stepIndex]) {
message.steps = [...message.steps];
// @ts-expect-error
message.steps[stepIndex] = { ...message.steps[stepIndex] };
// @ts-expect-error
callback(message.steps[stepIndex]);
} else {
message.steps = [
@@ -65,16 +67,6 @@ export async function streamRenderAIMessage(
event: AIStreamResponse;
}>(rawStream, async (event) => {
switch (event.type) {
/**
* A new step started (phase update).
*/
case 'response_step_start': {
updateProcessingMessageStep(event.stepIndex, (step) => {
step.phase = event.phase;
});
break;
}
/**
* The agent is processing a tool call in a new message.
*/
@@ -1,4 +1,5 @@
'use server';
import { getEmbeddableLinker } from '@/lib/embeddable';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -35,9 +36,10 @@ export async function* streamAIChatResponse({
options?: RenderAIMessageOptions;
}) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
const context = await getServerActionBaseContext({
isEmbeddable: options?.asEmbeddable,
});
let context = await getServerActionBaseContext();
if (options?.asEmbeddable) {
context = { ...context, linker: getEmbeddableLinker(context.linker) };
}
const siteURLData = await getSiteURLDataFromMiddleware();
@@ -6,14 +6,13 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
AIMessageStepPhase,
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { getVisitor, useTrackEvent } from '../Insights';
import { getSession } from '../Insights/sessions';
import { useSetSearchState } from '../Search';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
@@ -25,25 +24,8 @@ export type AIChatMessage = {
role: AIMessageRole;
content: React.ReactNode;
query?: string;
activity?: AIChatMessageActivity;
};
export type AIChatMessageActivity = {
currentPhase?: AIMessageStepPhase;
toolCount: number;
hasCommentary: boolean;
hasFinalAnswer: boolean;
};
export type AIChatStatus =
| 'default'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
export type AIChatState = {
/**
* If true, the chat is open.
@@ -226,7 +208,6 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
@@ -276,7 +257,10 @@ export function AIChatProvider(props: {
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: await getInsightsSession(),
session: {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
},
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
@@ -416,11 +400,6 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
@@ -581,78 +560,3 @@ export function useAIChatController(): AIChatController {
}
return controller;
}
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
if (chat.error) {
return 'error';
}
if (chat.control) {
return 'confirm';
}
if (chat.loading) {
const latestMessage = getLatestAssistantMessage(chat.messages);
const phase = latestMessage?.activity?.currentPhase;
switch (phase) {
case AIMessageStepPhase.Commentary:
return 'exploring';
case AIMessageStepPhase.FinalAnswer:
return 'working';
default:
return 'thinking';
}
}
if (chat.messages.length > 0) {
return 'done';
}
return 'default';
}
function getLatestAssistantMessage(messages: AIChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role === AIMessageRole.Assistant) {
return message;
}
}
return null;
}
function updateAIChatMessageActivity(
activity: AIChatMessageActivity,
event: AIStreamResponse
): AIChatMessageActivity {
switch (event.type) {
case 'response_step_start': {
return {
...activity,
currentPhase: event.phase,
hasCommentary:
activity.hasCommentary || event.phase === AIMessageStepPhase.Commentary,
hasFinalAnswer:
activity.hasFinalAnswer || event.phase === AIMessageStepPhase.FinalAnswer,
};
}
case 'response_tool_call': {
return {
...activity,
toolCount: activity.toolCount + 1,
};
}
default:
return activity;
}
}
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
return {
currentPhase: undefined,
toolCount: 0,
hasCommentary: false,
hasFinalAnswer: false,
};
}
@@ -9,7 +9,6 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
getAIChatStatus,
useAI,
useAIChatController,
useAIChatState,
@@ -108,7 +107,7 @@ export function AIChat() {
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody className="not-embed:px-0">
<EmbeddableFrameBody className="not-embed:px-4">
<AIChatBody
chatController={chatController}
chat={chat}
@@ -131,13 +130,24 @@ export function AIChatDynamicIcon(props: {
}) {
const { trademark, className } = props;
const chat = useAIChatState();
const status = getAIChatStatus(chat);
return (
<AIChatIcon
className={tcls('size-5 text-tint', className)}
trademark={trademark}
state={status}
state={
chat.error
? 'error'
: chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.control
? 'confirm'
: 'done'
: 'default'
}
/>
);
}
@@ -150,45 +160,59 @@ export function AIChatSubtitle(props: {
}) {
const { chat } = props;
const language = useLanguage();
const status = getAIChatStatus(chat);
const subtitleKey =
status === 'thinking'
? 'ai_chat_thinking'
: status === 'exploring'
? 'ai_chat_exploring'
: status === 'working'
? 'ai_chat_working'
: status === 'confirm'
? 'ai_chat_waiting'
: null;
return (
<EmbeddableFrameSubtitle
className={tcls('relative', subtitleKey ? 'h-3 opacity-11' : 'h-0 opacity-0')}
className={tcls(
'relative',
chat.loading || chat.control ? 'h-3 opacity-11' : 'h-0 opacity-0'
)}
>
{(
[
['thinking', 'ai_chat_thinking'],
['exploring', 'ai_chat_exploring'],
['working', 'ai_chat_working'],
['confirm', 'ai_chat_waiting'],
] as const
).map(([candidateStatus, key]) => (
<span
key={candidateStatus}
className={tcls(
'absolute left-0',
status === candidateStatus
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-display-slow'
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay: status === candidateStatus ? '.3s' : undefined,
}}
>
{subtitleKey ? t(language, key) : null}
</span>
))}
: 'hidden'
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay:
chat.messages[chat.messages.length - 1]?.content && !chat.control
? '.3s'
: undefined,
}}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-display-slow'
: 'animate-blur-in-display-slow'
: 'hidden'
)}
style={{
animationDelay:
chat.messages[chat.messages.length - 1]?.content || chat.control
? undefined
: '.3s',
}}
>
{t(language, 'ai_chat_thinking')}
</span>
<span
className={tcls(
'absolute left-0',
chat.control ? 'animate-blur-in-display-slow' : 'animate-blur-out-display-slow'
)}
style={{ animationDelay: chat.control ? '.3s' : undefined }}
>
{t(language, 'ai_chat_waiting')}
</span>
</EmbeddableFrameSubtitle>
);
}
@@ -226,7 +250,7 @@ export function AIChatBody(props: {
<>
<ScrollContainer
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 [scroll-behavior:smooth]"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
@@ -274,7 +298,7 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
/**
* Button to open/close the AI chat.
@@ -4,15 +4,7 @@ import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
size?: number;
state?:
| 'default'
| 'intro'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
trademark?: boolean;
}
@@ -33,7 +25,6 @@ export function AIChatIcon({
animation: {
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)',
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
@@ -52,7 +43,7 @@ export function AIChatIcon({
viewBox="0 0 16 16"
preserveAspectRatio="xMaxYMid meet"
className={className}
aria-busy={['thinking', 'exploring', 'working'].includes(state)}
aria-busy={state === 'thinking'}
overflow="visible"
{...props}
fill="none"
@@ -72,7 +63,6 @@ export function AIChatIcon({
animation: {
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)',
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
exploring: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
@@ -134,7 +124,6 @@ export function AIChatIcon({
animation: {
intro: '',
thinking: '',
exploring: '',
working: '',
done: '',
confirm: 'bounceSmall 1s infinite both',
@@ -157,7 +146,6 @@ export function AIChatIcon({
animation: {
intro: 'fadeIn 2s forwards',
thinking: '',
exploring: '',
working: '',
done: '',
confirm: '',
@@ -198,7 +186,6 @@ export function AIChatIcon({
animation: {
intro: 'pathEnter 1.5s both ease-out',
thinking: 'pathLoading 2s infinite both',
exploring: 'pathLoading 2s infinite both',
working: 'pathLoading 2s infinite both',
done: 'pathEnter 1s forwards ease',
confirm: '',
@@ -214,15 +201,7 @@ export function AIChatIcon({
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
state?:
| 'default'
| 'intro'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
}
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
@@ -234,7 +213,6 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
intro: 'fadeIn 1s both, orbit 1s cubic-bezier(0.16,1,0.3,1)',
thinking:
'fadeIn 1s both, orbit 1s orbit 2s 1s infinite forwards linear ease-out,',
exploring: '',
working: '',
done: '',
confirm: '',
@@ -256,7 +234,6 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
animation: {
intro: 'spin 2s .5s forwards cubic-bezier(0.16,1,0.3,1)',
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
@@ -1,16 +1,8 @@
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import {
type AIChatController,
type AIChatMessage,
type AIChatState,
getAIChatStatus,
} from '../AI';
import { ToggleChevron } from '../primitives';
import { Button } from '../primitives/Button';
import { Collapsible, CollapsibleTrigger } from '../primitives/Collapsible';
import type { AIChatController, AIChatState } from '../AI';
import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -19,11 +11,10 @@ export function AIChatMessages(props: {
chatController: AIChatController;
}) {
const { chat, chatController } = props;
const status = getAIChatStatus(chat);
const showLoadingShim = chat.loading && status !== 'working' && status !== 'done';
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type MessageGroup = { message: AIChatMessage; originalIndex: number };
type Message = (typeof chat.messages)[0];
type MessageGroup = { message: Message; originalIndex: number };
const messageGroups: Array<Array<MessageGroup>> = [];
let currentGroup: Array<MessageGroup> = [];
@@ -45,8 +36,6 @@ export function AIChatMessages(props: {
messageGroups.push(currentGroup);
}
const language = useLanguage();
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
return (
@@ -63,16 +52,8 @@ export function AIChatMessages(props: {
>
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
const toolCount = message.activity?.toolCount ?? 0;
const hasCommentary =
(message.activity?.hasCommentary ?? false) || toolCount > 0;
const hasFinalAnswer =
(message.activity?.hasFinalAnswer ?? false) || !isLastMessage;
return (
<Collapsible
open={!hasFinalAnswer}
disabled={!hasFinalAnswer}
<div
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
@@ -81,12 +62,12 @@ export function AIChatMessages(props: {
}
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-2',
'flex flex-col gap-6',
'break-words',
'group/message',
'animate-blur-in-slow',
message.role === AIMessageRole.User
? 'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong',
isLastMessage && message.role === AIMessageRole.Assistant
? 'grow'
@@ -96,45 +77,13 @@ export function AIChatMessages(props: {
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{message.role === AIMessageRole.Assistant &&
hasCommentary &&
hasFinalAnswer ? (
<CollapsibleTrigger asChild>
<Button
variant="blank"
size="small"
label={tString(language, 'ai_chat_view_activity')}
className="-mx-3 -my-1.5 group/dropdown animate-blur-in-display-slow self-start"
>
<div className="flex items-center gap-2">
{toolCount > 0
? t(
language,
'ai_chat_explored_with',
tString(
language,
toolCount === 1
? 'tool_count'
: 'tool_count_plural',
toolCount.toString()
)
)
: t(language, 'ai_chat_explored')}
<ToggleChevron orientation="right-to-down" />
</div>
</Button>
</CollapsibleTrigger>
) : null}
{message.content}
{isLastMessage && message.role === AIMessageRole.Assistant ? (
<div
className={tcls(
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
showLoadingShim
? 'max-h-48 opacity-11'
: 'pointer-events-none max-h-0 opacity-0'
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
chat.loading ? '' : 'hidden opacity-0'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
@@ -152,7 +101,7 @@ export function AIChatMessages(props: {
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1.5 -mt-4 mb-2"
className="-ml-1 -mt-4"
/>
) : null}
<AIChatFollowupSuggestions
@@ -161,7 +110,7 @@ export function AIChatMessages(props: {
/>
</>
) : null}
</Collapsible>
</div>
);
})}
</div>
@@ -30,7 +30,7 @@ export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-[calc(100%+2px)] left-1/2 flex items-center gap-1.5 rounded-lg border border-[#eaeaea] border-solid bg-white py-1 pr-1 pl-2"
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
This toolbar appears only when signed in to GitBook.
@@ -5,6 +5,7 @@ import {
SkeletonHeading,
SkeletonImage,
SkeletonParagraph,
SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind';
@@ -141,7 +142,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'file':
return <SkeletonHeading id={id} style={style} />;
case 'paragraph':
return <SkeletonParagraph id={id} className={style} />;
return <SkeletonSmall id={id} style={style} />;
case 'list-ordered':
case 'list-unordered':
case 'list-tasks':
@@ -152,7 +153,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'tabs':
case 'stepper-step':
case 'if':
return <SkeletonParagraph id={id} className={style} />;
return <SkeletonParagraph id={id} style={style} />;
case 'expandable':
case 'table':
case 'swagger':
@@ -172,7 +173,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'drawing':
return <SkeletonImage id={id} style={style} />;
case 'updates':
return <SkeletonUpdate id={id} className={style} />;
return <SkeletonUpdate id={id} style={style} />;
case 'image':
case 'code-line':
case 'tabs-item':
@@ -2,7 +2,6 @@ import type { DocumentBlock, JSONDocument } from '@gitbook/api';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
import { Block } from './Block';
import type { DocumentContextProps } from './DocumentView';
import { isBlockOffscreen } from './utils';
@@ -64,7 +63,7 @@ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
'columns',
'code',
'content-ref',
'divider',
'hint',
];
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
@@ -90,10 +89,12 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'decoration-primary/6',
FULL_WIDTH_BLOCKS.includes(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -3,11 +3,9 @@
import { useTheme } from 'next-themes';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import { Loading } from '@/components/primitives/Loading';
import { tcls } from '@/lib/tailwind';
import Panzoom from '@panzoom/panzoom';
import type { RenderResult } from 'mermaid';
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
import { getPlainCodeBlock } from './highlight';
@@ -16,9 +14,8 @@ import { getPlainCodeBlock } from './highlight';
* Used to render a Mermaid diagram from a CodeBlock.
*/
export function MermaidCodeBlock(props: ClientBlockProps) {
const { block, mode, style } = props;
const { block, style } = props;
const source = getPlainCodeBlock(block);
const rootRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
@@ -27,14 +24,8 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
const { resolvedTheme } = useTheme();
const darkMode = resolvedTheme === 'dark';
const id = useSafeId();
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
const shouldRender = mode === 'print' || hasBeenInViewport;
useEffect(() => {
if (!shouldRender) {
return;
}
const container = diagramRef.current;
const wrapper = wrapperRef.current;
if (!container || !wrapper) {
@@ -45,67 +36,46 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
let cleanupPanZoom: (() => void) | undefined;
setError(false);
setIsLoading(true);
setPanZoom(null);
container.innerHTML = '';
const cancelScheduledRender = scheduleMermaidWork(() => {
enqueueMermaidRender(async () => {
if (cancelled) {
return null;
}
return renderMermaidDiagram({
source,
id,
darkMode,
});
})
.then((result) => {
if (!result || cancelled) {
return;
}
container.innerHTML = result.svg;
if (container.querySelector('svg')) {
result.bindFunctions?.(container);
}
renderMermaidDiagram({
container,
source,
id,
darkMode,
})
.then(() => {
if (!cancelled) {
cleanupPanZoom = initPanzoom({
container,
wrapper,
onInit: setPanZoom,
});
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
});
}
})
.catch(() => {
if (!cancelled) {
setError(true);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
cancelScheduledRender();
cleanupPanZoom?.();
setPanZoom(null);
};
}, [source, id, darkMode, shouldRender]);
}, [source, id, darkMode]);
if (error) {
return <ClientCodeBlock {...props} />;
}
return (
<div
ref={rootRef}
className={tcls('group/mermaid relative', style)}
contentEditable={false}
>
<div className={tcls('relative', style)} contentEditable={false}>
<div
ref={wrapperRef}
className={
@@ -130,12 +100,18 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
}
async function renderMermaidDiagram(args: {
container: HTMLElement;
source: string;
id: string;
darkMode: boolean;
}): Promise<RenderResult> {
const { source, id, darkMode } = args;
const { mermaid } = await loadMermaid();
}) {
const { container, source, id, darkMode } = args;
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
import('mermaid'),
import('@mermaid-js/mermaid-zenuml'),
]);
await mermaid.registerExternalDiagrams([zenuml]);
mermaid.initialize({
startOnLoad: false,
@@ -144,115 +120,11 @@ async function renderMermaidDiagram(args: {
theme: darkMode ? 'dark' : undefined,
});
const renderContainer = createMermaidRenderContainer();
try {
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
} finally {
renderContainer.remove();
}
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
container.innerHTML = svg;
bindFunctions?.(container);
}
/**
* Mermaid measures labels while rendering, so the temporary render target must be
* connected to the document. Keep it fixed and contained so those layout reads do
* not walk the visible document flow.
*/
function createMermaidRenderContainer() {
const container = document.createElement('div');
container.setAttribute('aria-hidden', 'true');
Object.assign(container.style, {
contain: 'strict',
height: '100vh',
isolation: 'isolate',
left: '0',
overflow: 'hidden',
pointerEvents: 'none',
position: 'fixed',
top: '0',
visibility: 'hidden',
width: '100vw',
zIndex: '-1',
});
document.body.appendChild(container);
return container;
}
let mermaidLoadPromise: Promise<{
mermaid: typeof import('mermaid')['default'];
}> | null = null;
async function loadMermaid() {
if (!mermaidLoadPromise) {
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
await mermaid.registerExternalDiagrams([zenuml]);
return { mermaid };
})
.catch((error) => {
mermaidLoadPromise = null;
throw error;
});
}
return mermaidLoadPromise;
}
let mermaidRenderQueue = Promise.resolve();
function enqueueMermaidRender<T>(task: () => Promise<T>) {
const result = mermaidRenderQueue.then(task, task);
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
return result;
}
function waitForNextFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function scheduleMermaidWork(callback: () => void) {
const win = window as WindowWithIdleCallback;
let cancelled = false;
if (win.requestIdleCallback && win.cancelIdleCallback) {
const handle = win.requestIdleCallback(
() => {
if (!cancelled) {
callback();
}
},
{ timeout: 1500 }
);
return () => {
cancelled = true;
win.cancelIdleCallback?.(handle);
};
}
const handle = window.setTimeout(() => {
if (!cancelled) {
callback();
}
});
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}
type WindowWithIdleCallback = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (handle: number) => void;
};
/**
* Initialize panzoom on the diagram container.
*/
@@ -266,6 +138,7 @@ function initPanzoom(args: {
const instance = Panzoom(container, {
maxScale: 5,
minScale: 0.5,
contain: 'outside',
cursor: 'grab',
panOnlyWhenZoomed: true,
});
@@ -11,42 +11,70 @@ const PAN_STEP = 50;
*/
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
const { panZoom } = props;
const btnProps = {
variant: 'secondary' as const,
size: 'xsmall' as const,
iconOnly: true,
};
return (
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
{/* Row 1: empty, pan up, zoom in */}
<div />
<Button
{...btnProps}
label="Pan up"
icon="chevron-up"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
<Button
label="Zoom in"
icon="plus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomIn()}
/>
{/* Row 2: pan left, reset, pan right */}
<Button
{...btnProps}
label="Pan left"
icon="chevron-left"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
/>
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
<Button
{...btnProps}
label="Reset view"
icon="refresh"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.reset()}
/>
<Button
label="Pan right"
icon="chevron-right"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
/>
{/* Row 3: empty, pan down, zoom out */}
<div />
<Button
{...btnProps}
label="Pan down"
icon="chevron-down"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
/>
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
<Button
label="Zoom out"
icon="minus"
variant="secondary"
size="xsmall"
iconOnly
onClick={() => panZoom.zoomOut()}
/>
</div>
);
}
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props;
return <hr className={tcls(style, 'border-tint-subtle')} />;
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
}
@@ -93,7 +93,7 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
style={[
'mx-auto w-full decoration-primary/6',
block.data && 'fullWidth' in block.data && block.data.fullWidth
? 'max-w-6xl'
? 'max-w-screen-xl'
: 'max-w-3xl',
blockStyle,
]}
@@ -56,13 +56,12 @@ export function Details(props: {
'border-tint-subtle',
//all
'mt-0!',
'mb-0',
//select first child
'first:mt-5!',
'first:rounded-t-lg',
//select first in group
'[:not(&)_+_&]:mt-5!',
'[:not(&)_+_&]:rounded-t-lg',
'[:not(&)_+&]:mt-5!',
'[:not(&)_+&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
@@ -32,7 +32,7 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
})();
return (
<div className={tcls('flex flex-row gap-4 md:gap-8', style)}>
<div className={tcls('mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8', style)}>
<div className="relative select-none">
<div
className={tcls(
@@ -15,13 +15,11 @@ export function RecordRow(
}
) {
const { view, autoSizedColumns, fixedColumns, block, context } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
return (
<div
className={tcls(
'group/row flex',
'flex',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
@@ -35,7 +33,6 @@ export function RecordRow(
autoSizedColumns,
fixedColumns,
});
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
@@ -45,10 +42,7 @@ export function RecordRow(
role="cell"
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle',
isStickyFirstColumnCell
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
: undefined
'border-tint-subtle'
)}
style={{
width: columnWidth,
@@ -5,42 +5,12 @@ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from
interface StickyViewGridProps {
className?: string;
header?: ReactNode;
stickyHeader?: boolean;
header: ReactNode;
tableClassName?: string;
children: ReactNode;
}
export function StickyViewGrid(props: StickyViewGridProps) {
if (props.stickyHeader) {
return <StickyHeaderOverlayScrollGrid {...props} />;
}
return <DefaultHeaderScrollGrid {...props} />;
}
function DefaultHeaderScrollGrid({
className,
header,
tableClassName,
children,
}: StickyViewGridProps) {
const resolvedTableClassName = tableClassName ?? 'w-fit';
return (
<div className={className}>
<div className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle">
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
<div className={tcls('flex', 'flex-col', resolvedTableClassName)}>
{header}
{children}
</div>
</div>
</div>
</div>
);
}
function StickyHeaderOverlayScrollGrid({
export function StickyViewGrid({
className,
header,
tableClassName,
@@ -129,46 +99,48 @@ function StickyHeaderOverlayScrollGrid({
};
}, [syncStickyLayout]);
const resolvedTableClassName = tableClassName ?? 'w-fit';
return (
<div className={className}>
<div
ref={rootRef}
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
className={tcls(
'group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle',
'data-[scrollable=true]:border'
)}
data-scrollable="false"
>
{header ? (
<div
ref={stickyHeaderRef}
className={tcls(
'-mx-px sticky z-10 w-full min-w-0 max-w-full overflow-hidden rounded-t-[inherit] px-px',
'[top:var(--toc-top-offset,var(--outline-top-offset,0px))]'
)}
>
<div
ref={stickyHeaderRef}
className={tcls(
'sticky z-20 w-full min-w-0 max-w-full overflow-hidden rounded-t-[inherit]',
'[top:var(--toc-top-offset,var(--outline-top-offset,0px))]'
'flex',
'flex-col',
tableClassName ?? 'w-fit',
'[transform:translateX(var(--table-sticky-scroll-left,0px))]'
)}
style={{ width: 'var(--table-sticky-table-width)' }}
>
<div
className={tcls(
'flex',
'flex-col',
resolvedTableClassName,
// The sticky header is rendered outside the scroll container, so it
// needs to mirror the body's horizontal scroll position.
'[transform:translateX(var(--table-sticky-scroll-left,0px))]'
)}
style={{ width: 'var(--table-sticky-table-width)' }}
>
{header}
</div>
{header}
</div>
) : null}
</div>
<div
ref={bodyScrollRef}
className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle group-data-[scrollable=true]/table:mx-px group-data-[scrollable=true]/table:rounded-none group-data-[scrollable=true]/table:border-0"
className={tcls(
'w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle',
'group-data-[scrollable=true]/table:mx-px',
'group-data-[scrollable=true]/table:border-0',
'group-data-[scrollable=true]/table:rounded-none'
)}
>
<div
ref={bodyTableRef}
className={tcls('flex', 'flex-col', resolvedTableClassName)}
className={tcls('flex', 'flex-col', tableClassName ?? 'w-fit')}
>
{children}
</div>
@@ -53,33 +53,25 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
const withHeader = hasVisibleHeader(block, block.data.view);
const withStickyHeader =
withHeader && context.mode !== 'print' && block.data.view.stickyHeader === true;
const withStickyFirstColumn =
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
if (withStickyHeader || withStickyFirstColumn) {
if (withStickyHeader) {
return (
<StickyViewGrid
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
stickyHeader={withStickyHeader}
tableClassName={tableContainerClassName}
header={
withHeader ? (
<ViewGridHeader
{...gridProps}
tableClassName={tableContainerClassName}
className={tcls(
withStickyHeader
? [
'mb-0 border-t border-r border-l',
'group-data-[scrollable=false]/table:mb-1',
'group-data-[scrollable=false]/table:rounded-b-lg',
'group-data-[scrollable=true]/table:border-t-0',
'group-data-[scrollable=true]/table:border-x-0',
]
: undefined
)}
/>
) : undefined
<ViewGridHeader
{...gridProps}
tableClassName={tableContainerClassName}
className={tcls(
'mb-0 rounded-b-none border-t border-r border-l',
'group-data-[scrollable=false]/table:mb-1',
'group-data-[scrollable=false]/table:rounded-b-lg',
'group-data-[scrollable=true]/table:border-t-0',
'group-data-[scrollable=true]/table:border-r-0',
'group-data-[scrollable=true]/table:border-l-0'
)}
/>
}
>
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
@@ -23,9 +23,6 @@ export function ViewGridHeader(props: ViewGridHeaderProps) {
view,
mode: context.mode,
});
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const withStickyHeader = context.mode !== 'print' && view.stickyHeader === true;
const firstVisibleColumn = view.columns[0];
return (
<div
@@ -33,7 +30,6 @@ export function ViewGridHeader(props: ViewGridHeaderProps) {
className={tcls(
tableClassName ?? tableWidth,
'mb-1 flex flex-col rounded-lg border border-tint-subtle bg-tint',
withStickyHeader || !stickyFirstColumn ? 'overflow-hidden' : undefined,
className
)}
>
@@ -49,12 +45,7 @@ export function ViewGridHeader(props: ViewGridHeaderProps) {
key={column}
role="columnheader"
className={tcls(
'relative px-3 py-2 font-medium text-sm text-tint-strong',
stickyFirstColumn && column === firstVisibleColumn
? withStickyHeader
? 'z-20 bg-tint'
: 'sticky left-0 z-20 bg-tint'
: undefined,
'px-3 py-2 font-medium text-sm text-tint-strong',
getColumnAlignment(definition)
)}
style={{
@@ -65,12 +56,6 @@ export function ViewGridHeader(props: ViewGridHeaderProps) {
fixedColumns,
}),
minWidth: columnWidths?.[column] || '100px',
left:
withStickyHeader &&
stickyFirstColumn &&
column === firstVisibleColumn
? 'calc(-1 * var(--table-sticky-scroll-left, 0px))'
: undefined,
}}
title={definition.title}
>
@@ -10,9 +10,9 @@ import {
} from '@/components/AIChat';
import { useLanguage } from '@/intl/client';
import * as api from '@gitbook/api';
import React from 'react';
import React, { use, useMemo } from 'react';
import { useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { LinkContext, type LinkContextType } from '../primitives';
import {
EmbeddableFrame,
EmbeddableFrameBody,
@@ -27,7 +27,7 @@ import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
EmbeddableIframeTabs,
useEmbeddableLinkContext,
useEmbeddableConfiguration,
} from './EmbeddableIframeAPI';
type EmbeddableAIChatProps = {
@@ -43,6 +43,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const chat = useAIChatState();
const { config: siteConfig } = useAI();
const chatController = useAIChatController();
const embedConfig = useEmbeddableConfiguration((state) => state);
const language = useLanguage();
React.useEffect(() => {
@@ -64,8 +65,21 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const hasDocsTab = embedConfig.tabs.includes('docs');
const trademark = siteConfig.trademark;
const { linkContext } = useEmbeddableLinkContext();
const currentLinkContext = use(LinkContext);
const linkContext: LinkContextType = useMemo(
() =>
hasDocsTab
? { ...currentLinkContext, externalTarget: '_blank' }
: {
...currentLinkContext,
isExternalClient: () => true,
isExternalServer: () => true,
externalTarget: '_blank',
},
[hasDocsTab, currentLinkContext]
);
return (
<EmbeddableFrame>
@@ -12,7 +12,7 @@ type EmbeddableAIContextProviderProps = PropsWithChildren<AIConfig>;
*/
export function EmbeddableAIContextProvider(props: EmbeddableAIContextProviderProps) {
const { aiMode, suggestions, greeting, trademark, children } = props;
const embedConfig = useEmbeddableConfiguration();
const embedConfig = useEmbeddableConfiguration((state) => state);
return (
<AIContextProvider
@@ -58,13 +58,10 @@ export async function EmbeddableDocsPage(
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain
data-testid="embed-docs-page"
className={variants.generic.length > 1 ? 'has-sidebar' : 'no-sidebar'}
>
<EmbeddableFrameMain data-testid="embed-docs-page">
<div className="relative flex flex-col border-tint-subtle border-b theme-bold:bg-header-background">
<EmbeddableFrameHeader className="theme-bold:text-header-link">
<HeaderMobileMenu className="-ml-2 no-sidebar:hidden theme-bold:text-header-link hover:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link lg:hidden" />
<HeaderMobileMenu className="-ml-2 page-no-toc:hidden theme-bold:text-header-link hover:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link lg:hidden" />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>{context.site.title}</EmbeddableFrameTitle>
</EmbeddableFrameHeaderMain>
@@ -82,9 +79,7 @@ export async function EmbeddableDocsPage(
{context.sections ? (
<SiteSectionTabs
className="not-theme-bold:-mt-2 theme-bold:bg-tint-base"
sections={encodeClientSiteSections(context, context.sections, {
asEmbeddable: true,
})}
sections={encodeClientSiteSections(context, context.sections)}
>
{variants.translations.length > 1 ? (
<TranslationsDropdown
@@ -110,7 +105,6 @@ export async function EmbeddableDocsPage(
trailing={{ fade: false, button: true }}
>
<TableOfContents
className="layout-wide:no-sidebar:lg:hidden!"
context={context}
withTrademark={false}
header={
@@ -30,13 +30,11 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
}
);
export function EmbeddableFrameMain(
props: React.ComponentProps<'div'> & { className?: ClassValue }
) {
const { children, className, ...rest } = props;
export function EmbeddableFrameMain(props: React.ComponentProps<'div'>) {
const { children, ...rest } = props;
return (
<div className={tcls('flex flex-1 flex-col overflow-hidden', className)} {...rest}>
<div className="flex flex-1 flex-col overflow-hidden" {...rest}>
{children}
</div>
);
@@ -4,12 +4,12 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
import React, { useEffect, useRef } from 'react';
import { useAI, useAIChatController } from '@/components/AI';
import { tString, useLanguage } from '@/intl/client';
import { CustomizationAIMode } from '@gitbook/api';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button, LinkContext, type LinkContextType } from '../primitives';
import { Button } from '../primitives';
import { getChannel } from './channel';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
@@ -37,11 +37,12 @@ export function EmbeddableIframeAPI(props: {
const router = useRouter();
const chatController = useAIChatController();
const { setTheme } = useTheme();
// Live ref to avoid adding them as dependencies
const refs = useRef({ router, chatController, baseURL });
const refs = useRef({ router, chatController, baseURL, setTheme });
useEffect(() => {
refs.current = { router, chatController, baseURL };
refs.current = { router, chatController, baseURL, setTheme };
});
React.useEffect(() => {
@@ -58,7 +59,7 @@ export function EmbeddableIframeAPI(props: {
}
channel.receive((payload) => {
const { baseURL, router, chatController } = refs.current;
const { baseURL, router, chatController, setTheme } = refs.current;
const message = payload as ParentToFrameMessage;
log('[gitbook] received message', message);
@@ -79,6 +80,15 @@ export function EmbeddableIframeAPI(props: {
integrationsAssistantTools.setState({
tools: message.settings.tools,
});
if (Object.prototype.hasOwnProperty.call(message.settings, 'colorScheme')) {
const colorScheme = message.settings.colorScheme;
if (!colorScheme) {
setTheme('system');
} else {
setTheme(colorScheme);
}
}
break;
}
case 'navigateToPage': {
@@ -96,39 +106,11 @@ export function EmbeddableIframeAPI(props: {
return null;
}
/**
* Hook to get the configuration from the parent window.
*/
export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
// @ts-expect-error - This is a workaround to allow the function to be optional.
fn: (state: GitBookEmbeddableConfiguration) => T = (state) => state
) {
return useStore(embeddableConfiguration, fn);
}
export function useEmbeddableTabs() {
const configuredTabs = useEmbeddableConfiguration((state) => state.tabs);
return configuredTabs.length > 0 ? configuredTabs : ['assistant', 'search', 'docs'];
}
export function useEmbeddableLinkContext() {
const tabs = useEmbeddableTabs();
const hasDocsTab = tabs.includes('docs');
const currentLinkContext = React.useContext(LinkContext);
const linkContext: LinkContextType = React.useMemo(
() =>
hasDocsTab
? { ...currentLinkContext, externalTarget: '_blank' }
: {
...currentLinkContext,
isExternalClient: () => true,
isExternalServer: () => true,
externalTarget: '_blank',
},
[currentLinkContext, hasDocsTab]
);
return { hasDocsTab, linkContext };
/** Subscribe to embed configuration from the parent (Zustand). */
export function useEmbeddableConfiguration<T>(
selector: (state: GitBookEmbeddableConfiguration) => T
): T {
return useStore(embeddableConfiguration, selector);
}
/**
@@ -177,59 +159,55 @@ export function EmbeddableIframeTabs(props: {
siteTitle: string;
}) {
const { ref, active = 'assistant', baseURL, siteTitle } = props;
const actions = useEmbeddableConfiguration((state) => state.actions);
const tabs = useEmbeddableTabs();
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration((state) => state);
const { assistants, config } = useAI();
const language = useLanguage();
const router = useRouter();
const enabledTabs = [
const tabs = [
config.aiMode === CustomizationAIMode.Assistant &&
assistants[0] &&
tabs.includes('assistant')
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
? {
key: 'assistant',
label: assistants[0].label,
icon: assistants[0].icon,
href: `${baseURL}/assistant`,
onClick: () => {
router.push(`${baseURL}/assistant`);
},
}
: null,
tabs.includes('search')
? {
key: 'search',
label: tString(language, 'search'),
icon: 'search',
href: `${baseURL}/search`,
}
: null,
tabs.includes('docs')
configuredTabs.includes('docs') || configuredTabs.length === 0
? {
key: 'docs',
label: siteTitle,
icon: 'book-open',
href: `${baseURL}/page/`,
onClick: () => {
router.push(`${baseURL}/page/`);
},
}
: null,
].filter((tab) => tab !== null);
// Override the active tab if it doesn't match the configured tabs.
// Override the active tab if it doesn't match the configured tabs
React.useEffect(() => {
if (enabledTabs.length === 0) {
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;
}
const activeTab = enabledTabs.find((tab) => tab.key === active);
const fallbackTab = enabledTabs.at(0);
if (!activeTab && fallbackTab) {
router.replace(fallbackTab.href);
if (active === 'assistant' && !hasAssistant) {
router.replace(`${baseURL}/page`);
} else if (active === 'docs' && !hasDocs) {
router.replace(`${baseURL}/assistant`);
}
}, [enabledTabs, router, active]);
}, [tabs, baseURL, router, active]);
return enabledTabs.length > 1 || actions.length > 0 ? (
return tabs.length > 1 || actions.length > 0 ? (
<div className="flex flex-col items-center gap-2" ref={ref}>
{enabledTabs.map((tab) => (
{tabs.map((tab) => (
<Button
key={tab.key}
data-testid={`embed-tab-${tab.key}`}
@@ -240,9 +218,7 @@ export function EmbeddableIframeTabs(props: {
active={tab.key === active}
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={() => {
router.push(tab.href);
}}
onClick={tab.onClick}
tooltipProps={{
contentProps: {
side: 'right',
@@ -255,7 +231,7 @@ export function EmbeddableIframeTabs(props: {
}
export function EmbeddableIframeCloseButton() {
const { closeButton } = useEmbeddableConfiguration();
const { closeButton } = useEmbeddableConfiguration((state) => state);
if (!closeButton) {
return null;
@@ -6,8 +6,6 @@ import {
} from '@/components/SiteLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context';
import { resolveEmbeddableTheme } from '@/lib/embeddable';
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
@@ -20,7 +18,6 @@ type EmbeddableRootLayoutProps = {
context: GitBookSiteContext;
withTracking: boolean;
visitorAuthClaims: VisitorAuthClaims;
forcedTheme?: CustomizationDefaultThemeMode | null;
};
/**
@@ -30,22 +27,17 @@ export async function EmbeddableRootLayout({
context,
withTracking,
visitorAuthClaims,
forcedTheme,
children,
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
const theme = resolveEmbeddableTheme(context.customization, forcedTheme);
return (
<CustomizationRootLayout
context={context}
htmlClassName="embed"
forcedTheme={theme.htmlTheme}
>
<CustomizationRootLayout context={context} htmlClassName="embed">
<SiteLayoutClientContexts
forcedTheme={theme.forcedTheme}
defaultTheme={theme.defaultTheme}
// Keep embed theme separate from site so it does not reuse the full site's saved theme and vice versa.
themeStorageKey={`gitbook-theme-embed:${context.site.id}`}
forcedTheme={
context.customization.themes.toggeable
? undefined
: context.customization.themes.default
}
defaultTheme={context.customization.themes.default}
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
@@ -1,113 +0,0 @@
'use client';
import {
type SearchBaseProps,
SearchFrame,
SearchInput,
SearchLiveResultsAnnouncer,
SearchScopeControl,
useSearchController,
} from '@/components/Search';
import React from 'react';
import { useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
EmbeddableIframeTabs,
useEmbeddableLinkContext,
} from './EmbeddableIframeAPI';
type EmbeddableSearchProps = {
baseURL: string;
siteTitle: string;
searchProps: SearchBaseProps;
};
export function EmbeddableSearch(props: EmbeddableSearchProps) {
const { baseURL, searchProps, siteTitle } = props;
const { hasDocsTab, linkContext } = useEmbeddableLinkContext();
const trackEvent = useTrackEvent();
React.useEffect(() => {
trackEvent({
type: 'search_open',
});
}, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const {
askQuery,
cursor,
error,
fetching,
onInputKeyDown,
query,
results,
resultsId,
resultsRef,
searchValue,
setQuery,
showAsk,
withSearchAI,
scopeControl,
} = useSearchController({ ...searchProps, asEmbeddable: hasDocsTab });
return (
<LinkContext value={linkContext}>
<SearchFrame
asEmbeddable={hasDocsTab}
askQuery={askQuery}
cursor={cursor}
error={error}
fetching={fetching}
fillHeight={true}
query={query}
results={results}
resultsId={resultsId}
resultsRef={resultsRef}
showAsk={showAsk}
dataTestId="embed-search"
input={
<SearchInput
aria-activedescendant={
cursor !== null ? `${resultsId}-${cursor}` : undefined
}
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
withAI={withSearchAI}
isOpen={true}
mode="frame"
resultsCount={results.length}
fetching={fetching}
showAsk={showAsk}
>
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
}
sidebar={
<>
<EmbeddableIframeTabs
ref={tabsRef}
active="search"
baseURL={baseURL}
siteTitle={siteTitle}
/>
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</>
}
scopeControl={
searchProps.withVariants || searchProps.withSections ? (
<SearchScopeControl {...scopeControl} />
) : null
}
/>
</LinkContext>
);
}
@@ -1,22 +0,0 @@
import { getSearchBaseProps } from '@/components/Search/search-props';
import type { GitBookSiteContext } from '@/lib/context';
import { EmbeddableSearch } from './EmbeddableSearch';
type EmbeddableSearchPageProps = {
context: GitBookSiteContext;
};
/**
* Reusable page component for the embed search page.
*/
export function EmbeddableSearchPage(props: EmbeddableSearchPageProps) {
const { context } = props;
return (
<EmbeddableSearch
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
searchProps={getSearchBaseProps(context)}
/>
);
}
@@ -2,4 +2,3 @@ export * from './EmbeddableFrame';
export * from './EmbeddableRootLayout';
export * from './EmbeddableAssistantPage';
export * from './EmbeddableDocsPage';
export * from './EmbeddableSearchPage';
@@ -6,7 +6,7 @@ import { partition } from '@/lib/arrays';
import { tcls } from '@/lib/tailwind';
import { ThemeToggler } from '../ThemeToggler';
import { CONTAINER_STYLE, CONTENT_STYLE } from '../layout';
import { CONTAINER_STYLE } from '../layout';
import { FooterLinksGroup } from './FooterLinksGroup';
import { SocialAccountButton } from './SocialAccounts';
@@ -48,11 +48,16 @@ export function Footer(props: { context: GitBookSiteContext }) {
>
<div
className={tcls(
CONTENT_STYLE,
'flex @xs:grid @4xl:max-w-none! flex-col justify-between gap-12',
'mx-auto flex @xs:grid @4xl:max-w-none! max-w-3xl site-width-wide:max-w-screen-2xl flex-col justify-between gap-12',
'grid-cols-[auto_auto]',
'@4xl:grid-cols-[18rem_minmax(auto,48rem)_auto]',
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]'
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
'@4xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
'@7xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
'@4xl:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
'@7xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
'@4xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
'@7xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
)}
>
{
@@ -108,8 +113,12 @@ export function Footer(props: { context: GitBookSiteContext }) {
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<div className={tcls('col-span-2')}>
<div className="mx-auto flex @xl:flex-row flex-col @xl:gap-6 gap-10">
<div
className={tcls(
'@4xl:page-has-toc:col-span-1 @7xl:page-no-toc:col-span-1 col-span-2 @4xl:page-has-toc:col-start-2 @7xl:page-no-toc:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl @xl:flex-row flex-col @xl:gap-6 gap-10">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
@@ -5,7 +5,7 @@ import { getSpaceLanguage, t } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import type { SiteSpace } from '@gitbook/api';
import { SocialAccountButton } from '../Footer/SocialAccounts';
import { SearchContainer, getSearchBaseProps } from '../Search';
import { SearchContainer } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLink } from './HeaderLink';
import { HeaderLinkMore } from './HeaderLinkMore';
@@ -26,8 +26,7 @@ export function Header(props: {
};
}) {
const { context, withTopHeader, variants } = props;
const { siteSpace, visibleSections, customization } = context;
const searchProps = getSearchBaseProps(context);
const { siteSpace, visibleSiteSpaces, visibleSections, customization } = context;
const withSections = Boolean(
visibleSections &&
@@ -111,7 +110,7 @@ export function Header(props: {
'hover:site-header:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'no-sidebar:hidden lg:hidden'
: 'page-no-toc:hidden lg:hidden'
)}
/>
<HeaderLogo context={context} />
@@ -122,7 +121,7 @@ export function Header(props: {
'flex',
'grow-0',
'shrink-0',
'md:@2xl:basis-56',
'@2xl:basis-56',
'justify-self-end',
'items-center',
'gap-2',
@@ -130,23 +129,41 @@ export function Header(props: {
'search' in customization.styling &&
customization.styling.search === 'prominent'
? [
'md:@2xl:grow-[0.8]',
'md:@4xl:basis-40',
'md:@2xl:max-w-[50%]',
'md:@4xl:max-w-lg',
'@2xl:grow-[0.8]',
'@4xl:basis-40',
'@2xl:max-w-[50%]',
'@4xl:max-w-lg',
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'md:@2xl:mr-auto',
'@2xl:mr-auto',
'order-last',
'md:@2xl:order-[unset]',
'@2xl:order-[unset]',
]
: ['order-last']
)}
>
<SearchContainer
{...searchProps}
style={customization.styling.search}
withVariants={variants.generic.length > 1}
withSiteVariants={
visibleSections?.list.some(
(s) =>
s.object === 'site-section' && s.siteSpaces.length > 1
) ?? false
}
withSections={
visibleSections ? visibleSections.list.length > 1 : false
}
section={
visibleSections
? // Client-encode to avoid a serialization issue that was causing the language selector to disappear
encodeClientSiteSections(context, visibleSections).current
: undefined
}
siteSpace={siteSpace}
siteSpaces={visibleSiteSpaces}
viewport={!withTopHeader ? 'mobile' : undefined}
searchURL={context.linker.toPathInSpace('~gitbook/search')}
/>
</div>
@@ -1,16 +1,29 @@
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
import type { GitBookSiteContext } from '@/lib/context';
import {
type ContentRef,
type CustomizationContentLink,
type CustomizationHeaderItem,
type CustomizationHeaderPreset,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
import { getLocalizedTitle } from '@/lib/sites';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
import { tcls } from '@/lib/tailwind';
import {
SiteAuthLoginButton,
SiteAuthLoginDropdownMenuItem,
SiteAuthLoginLink,
} from '../SiteAuth/SiteAuthLoginLink';
import { Button, Link, ToggleChevron } from '../primitives';
import {
type DropdownButtonProps,
DropdownMenu,
DropdownMenuItem,
} from '../primitives/DropdownMenu';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -26,22 +39,29 @@ export async function HeaderLink(props: {
if (link.links && link.links.length > 0) {
return (
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={!!target}
linkTarget={link.to}
linkStyle={linkStyle}
href={target?.href}
isSiteAuthLoginHref={
target ? isSiteAuthLoginHref(context.linker, target.href) : false
<DropdownMenu
className={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
button={
!target || !link.to ? (
<HeaderItemDropdown headerPreset={headerPreset} title={title} />
) : (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown
href={target?.href}
isSiteAuthLoginHref={isSiteAuthLoginHref(context.linker, target.href)}
/>
)
}
dropdownClassName={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
openOnHover={true}
>
{link.links.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</HeaderLinkDropdown>
</DropdownMenu>
);
}
@@ -62,6 +82,148 @@ export async function HeaderLink(props: {
);
}
export type HeaderLinkNavItemProps = {
linkTarget: ContentRef;
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
headerPreset: CustomizationHeaderPreset;
title: string;
href?: string;
isDropdown: boolean;
isSiteAuthLoginHref: boolean;
} & DropdownButtonProps<HTMLElement>;
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
const { linkStyle, ...rest } = props;
switch (linkStyle) {
case 'button-secondary':
case 'button-primary':
return <HeaderItemButton {...rest} linkStyle={linkStyle} />;
case 'link':
return <HeaderItemLink {...rest} />;
default:
assertNever(linkStyle);
}
}
function HeaderItemButton(
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
linkStyle: 'button-secondary' | 'button-primary';
}
) {
const {
linkTarget,
linkStyle,
headerPreset,
title,
href,
isDropdown,
isSiteAuthLoginHref,
...rest
} = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'header';
case 'button-primary':
return 'primary';
default:
assertNever(linkStyle);
}
})();
const sharedProps: React.ComponentProps<typeof Button> = {
href,
variant,
size: 'medium' as const,
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
label: title,
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
}
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
return tcls(
'flex items-center gap-1',
'shrink',
'contrast-more:underline',
'truncate',
'text-tint',
'links-default:hover:text-primary',
'links-default:data-[state=open]:text-primary',
'links-default:tint:hover:text-tint-strong',
'links-default:tint:data-[state=open]:text-tint-strong',
'underline-offset-2',
'links-accent:hover:underline',
'links-accent:data-[state=open]:underline',
'links-accent:underline-offset-4',
'links-accent:decoration-primary-subtle',
'links-accent:decoration-[3px]',
'links-accent:py-0.5', // Prevent underline from being cut off at the bottom
'theme-bold:text-header-link',
'hover:theme-bold:text-header-link/7!'
);
}
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const { linkTarget, headerPreset, title, isDropdown, href, isSiteAuthLoginHref, ...rest } =
props;
const sharedProps = {
href: href ?? '#',
className: getHeaderLinkClassName({ headerPreset }),
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginLink {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</SiteAuthLoginLink>
) : (
<Link {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</Link>
);
}
function HeaderItemDropdown(
props: {
headerPreset: CustomizationHeaderPreset;
title: string;
} & DropdownButtonProps<HTMLElement>
) {
const { headerPreset, title, ...rest } = props;
return (
<span
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default')}
{...rest}
>
{title}
<ToggleChevron />
</span>
);
}
async function SubHeaderLink(props: {
context: GitBookSiteContext;
link: CustomizationContentLink;
@@ -1,216 +0,0 @@
'use client';
import {
type ContentRef,
type CustomizationHeaderPreset,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import assertNever from 'assert-never';
import type React from 'react';
import { tcls } from '@/lib/tailwind';
import { SiteAuthLoginButton, SiteAuthLoginLink } from '../SiteAuth/SiteAuthLoginLink';
import { Button, Link, ToggleChevron } from '../primitives';
import { type DropdownButtonProps, DropdownMenu } from '../primitives/DropdownMenu';
/**
* Client component wrapping the DropdownMenu for header links.
* Accepts serializable props to avoid Flight lazy chunk wrappers reaching Radix's Slot.
*/
export function HeaderLinkDropdown(props: {
headerPreset: CustomizationHeaderPreset;
title: string;
hasTarget: boolean;
linkTarget: ContentRef | null;
linkStyle: 'link' | 'button-secondary' | 'button-primary';
href?: string;
isSiteAuthLoginHref: boolean;
dropdownClassName: string;
children: React.ReactNode;
}) {
const {
headerPreset,
title,
hasTarget,
linkTarget,
linkStyle,
href,
isSiteAuthLoginHref,
dropdownClassName,
children,
} = props;
return (
<DropdownMenu
className={dropdownClassName}
button={
!hasTarget || !linkTarget ? (
<HeaderItemDropdown headerPreset={headerPreset} title={title} />
) : (
<HeaderLinkNavItem
linkTarget={linkTarget}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={title}
isDropdown
href={href}
isSiteAuthLoginHref={isSiteAuthLoginHref}
/>
)
}
openOnHover={true}
>
{children}
</DropdownMenu>
);
}
export type HeaderLinkNavItemProps = {
linkTarget: ContentRef;
linkStyle: NonNullable<'link' | 'button-secondary' | 'button-primary'>;
headerPreset: CustomizationHeaderPreset;
title: string;
href?: string;
isDropdown: boolean;
isSiteAuthLoginHref: boolean;
} & DropdownButtonProps<HTMLElement>;
export function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
const { linkStyle, ...rest } = props;
switch (linkStyle) {
case 'button-secondary':
case 'button-primary':
return <HeaderItemButton {...rest} linkStyle={linkStyle} />;
case 'link':
return <HeaderItemLink {...rest} />;
default:
assertNever(linkStyle);
}
}
function HeaderItemButton(
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
linkStyle: 'button-secondary' | 'button-primary';
}
) {
const {
linkTarget,
linkStyle,
headerPreset,
title,
href,
isDropdown,
isSiteAuthLoginHref,
...rest
} = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'header';
case 'button-primary':
return 'primary';
default:
assertNever(linkStyle);
}
})();
const sharedProps: React.ComponentProps<typeof Button> = {
href,
variant,
size: 'medium' as const,
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
label: title,
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginButton {...sharedProps} />
) : (
<Button {...sharedProps} />
);
}
function getHeaderLinkClassName(_props: { headerPreset: CustomizationHeaderPreset }) {
return tcls(
'flex items-center gap-1',
'shrink',
'contrast-more:underline',
'truncate',
'text-tint',
'links-default:hover:text-primary',
'links-default:data-[state=open]:text-primary',
'links-default:tint:hover:text-tint-strong',
'links-default:tint:data-[state=open]:text-tint-strong',
'underline-offset-2',
'links-accent:hover:underline',
'links-accent:data-[state=open]:underline',
'links-accent:underline-offset-4',
'links-accent:decoration-primary-subtle',
'links-accent:decoration-[3px]',
'links-accent:py-0.5', // Prevent underline from being cut off at the bottom
'theme-bold:text-header-link',
'hover:theme-bold:text-header-link/7!'
);
}
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const {
linkTarget,
headerPreset,
title,
isDropdown,
href,
isSiteAuthLoginHref,
className,
...rest
} = props;
const sharedProps = {
href: href ?? '#',
className: tcls(getHeaderLinkClassName({ headerPreset }), className),
insights: {
type: 'link_click' as const,
link: {
target: linkTarget,
position: SiteInsightsLinkPosition.Header,
},
},
...rest,
};
return isSiteAuthLoginHref ? (
<SiteAuthLoginLink {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</SiteAuthLoginLink>
) : (
<Link {...sharedProps}>
{title}
{isDropdown ? <ToggleChevron /> : null}
</Link>
);
}
function HeaderItemDropdown(
props: {
headerPreset: CustomizationHeaderPreset;
title: string;
} & DropdownButtonProps<HTMLElement>
) {
const { headerPreset, title, className, ...rest } = props;
return (
<span
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default', className)}
{...rest}
>
{title}
<ToggleChevron />
</span>
);
}
@@ -6,6 +6,7 @@ import {
SiteInsightsLinkPosition,
type SiteSocialAccount,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
@@ -14,12 +15,13 @@ import { tcls } from '@/lib/tailwind';
import { SocialAccountLink } from '../Footer/SocialAccounts';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { ToggleChevron } from '../primitives';
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownSubMenu,
} from '../primitives/DropdownMenu';
import { HeaderLinkMoreDropdown } from './HeaderLinkMoreClient';
import styles from './headerLinks.module.css';
/**
@@ -33,11 +35,32 @@ export function HeaderLinkMore(props: {
}) {
const { label, links, context, socialAccounts } = props;
const renderButton = (
<button
type="button"
className={tcls(
'text-tint',
'hover:text-primary',
'dark:hover:text-primary',
'theme-bold:text-header-link',
'theme-bold:hover:text-header-link/8',
'flex',
'gap-1',
'items-center'
)}
>
<span className="sr-only">{label}</span>
<Icon icon="ellipsis" className={tcls('size-4')} />
<ToggleChevron />
</button>
);
return (
<div className={`${styles.linkEllipsis} z-20 items-center`}>
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={tcls(
<DropdownMenu
button={renderButton}
openOnHover={true}
className={tcls(
'max-md:right-0 max-md:left-auto',
context.customization.styling.search === 'prominent' && 'right-0 left-auto'
)}
@@ -52,7 +75,7 @@ export function HeaderLinkMore(props: {
account={account}
/>
))}
</HeaderLinkMoreDropdown>
</DropdownMenu>
</div>
);
}
@@ -1,48 +0,0 @@
'use client';
import { Icon } from '@gitbook/icons';
import type React from 'react';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
/**
* Client component for the "More" header link dropdown.
* Creates the trigger button internally to avoid Flight lazy chunk wrappers reaching Radix's Slot.
*/
export function HeaderLinkMoreDropdown(props: {
label: React.ReactNode;
dropdownClassName: ClassValue;
children: React.ReactNode;
}) {
const { label, dropdownClassName, children } = props;
return (
<DropdownMenu
button={
<button
type="button"
className={tcls(
'text-tint',
'hover:text-primary',
'dark:hover:text-primary',
'theme-bold:text-header-link',
'theme-bold:hover:text-header-link/8',
'flex',
'gap-1',
'items-center'
)}
>
<span className="sr-only">{label}</span>
<Icon icon="ellipsis" className={tcls('size-4')} />
<ToggleChevron />
</button>
}
openOnHover={true}
className={dropdownClassName}
>
{children}
</DropdownMenu>
);
}
@@ -11,14 +11,6 @@ interface HeaderLogoProps {
context: GitBookSiteContext;
}
/**
* We previously gave the logo too much height, making it too big to look good.
* As a response, many orgs added padding inside their logos to make them look better.
* The new logo max-height looks better but might make existing logos look too small.
* To avoid disruption, we only use the new (correct) logo height for logos updated after this date.
*/
const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
/**
* Render the logo for a space using the customization settings.
*/
@@ -26,10 +18,6 @@ const HEADER_COMPACT_LOGO_DATE = '2026-04-28T00:00:00.000Z';
export async function HeaderLogo(props: HeaderLogoProps) {
const { context } = props;
const { customization, linker } = context;
const canUseCompactHeaderLogo =
customization.updatedAt &&
!Number.isNaN(Date.parse(customization.updatedAt)) &&
Date.parse(customization.updatedAt) >= Date.parse(HEADER_COMPACT_LOGO_DATE);
const primaryLink = customization.header.primaryLink
? await resolveContentRef(customization.header.primaryLink, context)
@@ -71,7 +59,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
'max-w-40',
'lg:max-w-64',
'lg:site-header-none:page-no-toc:max-w-56',
canUseCompactHeaderLogo ? 'max-h-8' : 'max-h-10',
'max-h-10',
'h-full',
'w-full',
'object-contain',
@@ -1,11 +1,12 @@
import type { SiteSpace } from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { useMemo } from 'react';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import type { ButtonProps } from '../primitives';
import { SpacesDropdownClient } from './SpacesDropdownClient';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
@@ -20,34 +21,43 @@ export function SpacesDropdown(props: {
siteSpaces: SiteSpace[];
className?: string;
variant?: ButtonProps['variant'];
icon?: IconName;
icon?: ButtonProps['icon'];
}) {
const { context, siteSpace, siteSpaces, className, variant = 'secondary', icon } = props;
const currentLanguage = context.locale;
const dropdownClassName = tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
);
const slimSpaces = siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}));
return (
<SpacesDropdownClient
title={getLocalizedTitle(siteSpace, currentLanguage)}
icon={icon}
variant={variant}
className={className}
dropdownClassName={dropdownClassName}
slimSpaces={slimSpaces}
curPath={siteSpace.path}
/>
<DropdownMenu
className={tcls(
'group-hover/dropdown:invisible', // Prevent hover from opening the dropdown, as it's annoying in this context
'group-focus-within/dropdown:group-hover/dropdown:visible' // When the dropdown is already open, it should remain visible when hovered
)}
button={
<Button
icon={icon}
data-testid="space-dropdown-button"
size="small"
variant={variant}
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">
{getLocalizedTitle(siteSpace, currentLanguage)}
</span>
</Button>
}
>
<SpacesDropdownMenuItems
slimSpaces={siteSpaces.map((siteSp) => ({
id: siteSp.id,
title: getLocalizedTitle(siteSp, currentLanguage),
url: getSiteSpaceURL(context, siteSp),
isActive: siteSp.id === siteSpace.id,
spaceId: siteSp.space.id,
}))}
curPath={siteSpace.path}
/>
</DropdownMenu>
);
}
@@ -59,8 +69,9 @@ export function TranslationsDropdown(props: {
}) {
const { context, siteSpace, siteSpaces, className } = props;
// Memoize the emoji check to avoid repeated regex execution
const title = getLocalizedTitle(siteSpace, context.locale);
const hasEmojiPrefix = startsWithEmoji(title);
const hasEmojiPrefix = useMemo(() => startsWithEmoji(title), [title]);
return (
<SpacesDropdown
@@ -1,50 +0,0 @@
'use client';
import type { IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
/**
* Client component that creates the Button trigger and DropdownMenu internally,
* avoiding server→client element serialization through Radix's Slot.
*/
export function SpacesDropdownClient(props: {
title: string;
icon?: IconName;
variant: ButtonProps['variant'];
className?: ClassValue;
dropdownClassName: string;
slimSpaces: Array<{
id: string;
title: string;
url: string;
isActive: boolean;
spaceId: string;
}>;
curPath: string;
}) {
const { title, icon, variant, className, dropdownClassName, slimSpaces, curPath } = props;
return (
<DropdownMenu
className={dropdownClassName}
button={
<Button
icon={icon}
data-testid="space-dropdown-button"
size="small"
variant={variant}
trailing={<ToggleChevron />}
className={tcls('bg-tint-base', className)}
>
<span className="button-content">{title}</span>
</Button>
}
>
<SpacesDropdownMenuItems slimSpaces={slimSpaces} curPath={curPath} />
</DropdownMenu>
);
}
@@ -1,8 +1,5 @@
'use client';
import type { SiteInsightsSession } from '@gitbook/api';
import type { MaybePromise } from 'p-map';
import React from 'react';
import { createStore, useStore } from 'zustand';
import {
@@ -12,8 +9,9 @@ import {
setLocalStorageItem,
} from '@/lib/browser';
import type { MaybePromise } from 'p-map';
import React from 'react';
import { isCookiesTrackingDisabled } from './cookies';
import { getSession } from './sessions';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
@@ -128,16 +126,6 @@ export function useVisitor() {
return useStore(visitorStore, (state) => state.visitor);
}
/**
* Get the visitor session for insights.
*/
export async function getInsightsSession(): Promise<SiteInsightsSession> {
return {
sessionId: getSession().id,
visitorId: (await getVisitor()).deviceId,
};
}
/**
* Get the current visitor ID.
*/
@@ -1,23 +1,21 @@
import { getSpaceLanguage, t } from '@/intl/server';
import type { GitBookSiteContext } from '@/lib/context';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import {
type JSONDocument,
type RevisionPageDocument,
SiteAdsStatus,
SiteInsightsAdPlacement,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { Icon } from '@gitbook/icons';
import { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { Ad } from '../Ads';
import { PageFeedbackForm } from '../PageFeedback';
import { ThemeToggler } from '../ThemeToggler';
import { SideSheet } from '../primitives/SideSheet';
import { PageAsideCloseButton } from './PageAsideButton';
import { ScrollSectionsList } from './ScrollSectionsList';
import { ScrollToTopButton } from './ScrollToTopButton';
/**
* Aside listing the headings in the document.
@@ -32,20 +30,46 @@ export function PageAside(props: {
}) {
const { page, document, withPageFeedback, context } = props;
const { customization, site } = context;
const language = getSpaceLanguage(context);
return (
<SideSheet
side="right"
toggleClass="outline-open"
withOverlay
withCloseButton
<aside
className={tcls(
'group/aside',
'order-last',
'hidden',
'max-w-0',
'pt-8',
'pb-4',
'opacity-0',
'xl:sticky',
'w-64',
'xl:flex',
'overflow-hidden',
'xl:max-w-56',
'xl:opacity-11',
'xl:ml-12',
'xl:max-3xl:chat-open:hidden',
'xl:max-3xl:chat-open:max-w-0',
'xl:max-3xl:chat-open:opacity-0',
'xl:max-3xl:chat-open:ml-0',
'hydrated:starting:ml-0',
'hydrated:starting:max-w-0',
'hydrated:starting:opacity-0',
'transition-[margin,max-width,opacity,display] duration-300 motion-reduce:transition-none',
'transition-discrete',
'basis-56',
'grow-0',
'shrink-0',
'break-anywhere', // To prevent long words in headings from breaking the layout
'text-tint',
'contrast-more:text-tint-strong',
'sticky',
// Without header
'lg:top-0',
@@ -63,68 +87,80 @@ export function PageAside(props: {
'lg:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:[html[style*="--outline-height"]_&]:max-h-(--outline-height)!',
'layout-default:max-xl:border-l',
'layout-wide:max-3xl:border-l',
'border-tint-subtle',
'p-4',
'pt-8',
'ml-4',
'break-anywhere', // To prevent long words in headings from breaking the layout
'lg:z-10',
'layout-default:xl:not-chat-open:pr-0',
'layout-default:xl:not-chat-open:pl-8',
'layout-default:xl:not-chat-open:flex!',
'layout-default:xl:not-chat-open:animate-none!',
'layout-default:3xl:flex!',
'layout-default:3xl:animate-none!',
// In layout-wide mode, hide outline when viewport is too narrow
// or when chat is open and viewport is narrow, to prevent layout overflow
'layout-wide:xl:-mr-68',
'layout-wide:3xl:not-chat-open:flex!',
'layout-wide:3xl:not-chat-open:animate-none!',
// Show outline if page has OpenAPI block
// TODO: remove this in favour of a nicer, more immediately accessible solution in the future.
'page-api-block:page-has-outline:min-[96rem]:max-3xl:-mr-[max(calc((100vw-90rem)/2),0rem)]',
'page-api-block:page-has-outline:min-[96rem]:flex!',
'page-api-block:page-has-outline:min-[96rem]:animate-none!',
'page-api-block:page-has-outline:min-[96rem]:border-l-0',
'page-api-block:page-has-outline:min-[96rem]:pl-8',
'hydrated:site-background', // Only add a background once the element is positioned correctly to prevent overlapping the page cover
'text-tint',
'contrast-more:text-tint-strong'
// When in api page mode, we display it as an overlay on non-large resolutions
'xl:max-2xl:page-api-block:z-10',
'xl:max-2xl:page-api-block:fixed',
'xl:max-2xl:page-api-block:right-8',
'xl:max-2xl:page-api-block:w-60',
'xl:max-2xl:page-api-block:max-w-60',
'xl:max-2xl:page-api-block:pb-8',
'xl:max-2xl:page-api-block:pt-10',
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
)}
>
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
<div
className={tcls(
'flex flex-col',
'min-w-56 shrink-0',
'overflow-hidden',
'w-full',
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
'xl:max-2xl:page-api-block:border',
'xl:max-2xl:page-api-block:border-tint',
'xl:max-2xl:page-api-block:bg-tint/9',
'xl:max-2xl:page-api-block:backdrop-blur-lg',
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
'xl:max-2xl:page-api-block:hover:shadow-lg',
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
'xl:max-2xl:page-api-block:not-hover:*:hidden'
)}
>
<PageAsideHeader context={context} />
{page.layout.outline ? (
<>
<div className="mb-3 ml-3 flex page-no-outline:hidden items-center justify-between max-lg:hidden">
<ScrollToTopButton className="flex cursor-pointer items-center gap-1 font-semibold text-tint text-xs uppercase leading-wider">
<Icon icon="block-quote" className="size-3" />{' '}
{t(language, 'on_this_page')}
</ScrollToTopButton>
<PageAsideCloseButton className="layout-wide:3xl:hidden layout-default:xl:hidden page-api-block:min-[96rem]:hidden" />
</div>
<div className="flex shrink flex-col overflow-hidden">
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<PageAsideActions page={page} withPageFeedback={withPageFeedback} />
</div>
</>
<div className="flex shrink flex-col overflow-hidden">
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<PageAsideActions page={page} withPageFeedback={withPageFeedback} />
</div>
) : null}
{customization.themes.toggeable || site.ads ? (
<PageAsideFooter context={context} />
) : null}
</div>
</SideSheet>
</aside>
);
}
function PageAsideHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const language = getSpaceLanguage(context);
return (
<div
className={tcls(
'hidden',
'xl:max-2xl:page-api-block:flex!',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'px-2',
'py-1.5',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon icon="chevron-down" className={tcls('size-3', 'opacity-6', 'ml-auto')} />
</div>
);
}
@@ -134,7 +170,7 @@ async function PageAsideSections(props: { document: JSONDocument; context: GitBo
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? (
<div data-gb-page-outline className="overflow-y-auto">
<div className="overflow-y-auto">
<ScrollSectionsList sections={sections} />
</div>
) : null;
@@ -151,7 +187,7 @@ function PageAsideActions(props: {
className={tcls(
'flex flex-col gap-3',
'border-tint-subtle border-t first:border-none',
'sidebar-list-default:px-3 pt-5 first:pt-0',
'sidebar-list-default:px-3 pt-5 first:pt-0 xl:max-2xl:page-api-block:p-5',
'empty:hidden'
)}
>
@@ -172,7 +208,8 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col',
'border-tint-subtle',
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'pt-4'
)}
>
@@ -1,60 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
import { Button } from '../primitives';
const globalClassName = 'outline-open';
export function PageAsideToggleButton() {
const language = useLanguage();
const pathname = usePathname();
// Close the navigation when navigating to a page
useEffect(() => {
document.body.classList.remove(globalClassName);
}, [pathname]);
return (
<>
<Button
icon="block-quote"
size="xsmall"
variant="secondary"
label={tString(language, 'on_this_page')}
onClick={() => {
document.body.classList.toggle(globalClassName);
}}
className="layout-default:hidden layout-wide:3xl:hidden page-no-outline:hidden layout-wide:max-xl:hidden page-api-block:min-[96rem]:hidden"
/>
<Button
icon="block-quote"
size="xsmall"
iconOnly
variant="secondary"
label={tString(language, 'on_this_page')}
onClick={() => {
document.body.classList.toggle(globalClassName);
}}
className="page-no-outline:hidden xl:hidden"
/>
</>
);
}
export function PageAsideCloseButton(props: { className?: string }) {
return (
<Button
icon="xmark"
size="small"
iconOnly
variant="blank"
onClick={() => {
document.body.classList.toggle(globalClassName);
}}
className={props.className}
/>
);
}
@@ -43,7 +43,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
return (
<ul
className="relative flex flex-col border-tint-subtle sidebar-list-line:border-l pb-5"
className="relative flex flex-col border-tint-subtle sidebar-list-line:border-l pb-5 xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2"
ref={scrollContainerRef}
>
{sections.map((section) => (
@@ -1,18 +0,0 @@
'use client';
import type { ComponentPropsWithRef } from 'react';
export function ScrollToTopButton(props: Omit<ComponentPropsWithRef<'button'>, 'type'>) {
return (
<button
{...props}
type="button"
onClick={(event) => {
props.onClick?.(event);
if (!event.isDefaultPrevented()) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
/>
);
}
@@ -10,7 +10,6 @@ import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { TrackPageViewEvent } from '../Insights';
import { PageFeedbackForm } from '../PageFeedback';
import { CurrentPageProvider } from '../hooks/useCurrentPage';
import { CONTENT_STYLE } from '../layout';
import { DateRelative, SuspenseLoadedHint } from '../primitives';
import OptionalSuspense from './OptionalSuspense';
import { PageBodyBlankslate } from './PageBodyBlankslate';
@@ -41,6 +40,8 @@ export function PageBody(props: {
} = props;
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
// Update blocks can only be at the top level of the document, so we optimize the check.
const contentHasUpdates = document
? hasTopLevelBlock(document, (block) => block.type === 'updates')
@@ -54,21 +55,14 @@ export function PageBody(props: {
LINK_PREVIEW_MAX_COUNT
)
: false;
// Determine if content should use wide layout (2-column or 1-column instead of 3-column)
// This happens when: (1) document has full-width blocks, OR (2) page layout is explicitly set to 'wide'
const wideContent = document ? hasFullWidthBlock(document) : false;
const wideLayout = wideContent || page.layout.width === 'wide';
const pageWidthWide = page.layout.width === 'wide';
const siteWidthWide = pageWidthWide || contentFullWidth;
const language = getSpaceLanguage(context);
const updatedAt = page.updatedAt ?? page.createdAt;
const hasVisibleTOCItems =
context.revision.pages.filter(
(page) =>
page.type === 'link' ||
page.type === 'computed' ||
(page.type === 'group' && !page.hidden) ||
(page.type === 'document' && !page.hidden)
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
).length > 0;
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
@@ -78,16 +72,16 @@ export function PageBody(props: {
<main
className={tcls(
'relative min-w-0 flex-1',
'break-anywhere', // Allow words to break if they are too long.
'py-8',
'layout-wide:no-sidebar:lg:max-xl:pb-20', // Add padding to prevent overlap of minimised trademark
'max-w-screen-2xl py-8',
// Allow words to break if they are too long.
'break-anywhere',
'@container',
CONTENT_STYLE,
pageHasToc ? 'page-has-toc' : 'page-no-toc',
wideLayout ? 'layout-wide' : 'layout-default'
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
pageHasToc ? 'page-has-toc' : 'page-no-toc'
)}
>
<PreservePageLayout wideLayout={wideLayout} pageHasToc={pageHasToc} />
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -101,12 +95,18 @@ export function PageBody(props: {
{document && !isNodeEmpty(document) ? (
<OptionalSuspense
staticRoute={staticRoute}
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
fallback={
<DocumentViewSkeleton
document={document}
blockStyle="page-api-block:ml-0"
/>
}
>
<SuspenseLoadedHint />
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
blockStyle="page-api-block:ml-0"
context={{
mode: 'default',
contentContext: {
@@ -125,35 +125,28 @@ export function PageBody(props: {
<PageFooterNavigation context={context} page={page} />
) : null}
{page.layout.metadata ? (
<div
className={tcls(
CONTENT_STYLE,
'mt-6 flex flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong'
)}
>
{updatedAt ? (
<p className="mr-auto text-sm ">
{t(
language,
'page_last_modified',
<DateRelative value={updatedAt} />
)}
</p>
) : null}
{withPageFeedback ? (
<PageFeedbackForm
className={
// Hide feedback form when outline is visible on desktop, but show it in some special cases
page.layout.outline
? 'layout-wide:chat-open:max-[2416px]:flex layout-wide:max-3xl:flex xl:hidden xl:max-3xl:chat-open:flex'
: ''
}
pageId={page.id}
/>
) : null}
</div>
) : null}
{
// TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
page.layout.metadata !== false ? (
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
{updatedAt ? (
<p className="mr-auto text-sm ">
{t(
language,
'page_last_modified',
<DateRelative value={updatedAt} />
)}
</p>
) : null}
{withPageFeedback ? (
<PageFeedbackForm
className={page.layout.outline ? 'xl:hidden' : ''}
pageId={page.id}
/>
) : null}
</div>
) : null
}
</main>
<TrackPageViewEvent displayContext={insightsDisplayContext} />
@@ -10,7 +10,6 @@ import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { PageIcon } from '../PageIcon';
import { CONTENT_STYLE } from '../layout';
/**
* Blankslate when the page has no document or the document is empty.
@@ -77,12 +76,15 @@ export async function PageBodyBlankslate(props: {
return (
<div
className={tcls(
CONTENT_STYLE,
'grid',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
'w-full',
'mx-auto',
'gap-4',
'grid-cols-1',
'sm:grid-cols-2',
'md:layout-wide:grid-cols-3'
'md:page-width-wide:grid-cols-3'
)}
>
{pageElements}

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