Compare commits

...

35 Commits

Author SHA1 Message Date
Nicolas Dorseuil b9c733417f Implement graceful error handling for streaming server actions 2026-04-22 14:31:40 +02:00
Nicolas Dorseuil 49694d493e add a warning 2026-04-21 18:19:37 +02:00
Nicolas Dorseuil 9d8a0b8832 Handle revision fetch failures gracefully in streamAskQuestion 2026-04-21 17:16:06 +02:00
conico974 be080248b1 Block search indexation in deployment configuration (#4203) 2026-04-20 16:22:34 +02:00
Samy Pessé d3d51507e5 Add "Content-Signal" to robots.txt (#4202) 2026-04-20 14:14:59 +02:00
Addison a17ab65a6d Show variants in fully hidden sections (#4201) 2026-04-20 16:41:24 +09:00
Zeno Kapitein e3aca9e4f6 Fix embeddable search links (#4196) 2026-04-18 00:43:07 +02:00
spastorelli 045f603849 Add ~gitbook/auth/logout endpoint in GBO (#4195) 2026-04-16 22:40:56 +02:00
Greg Bergé b2854500a1 Allow to scroll to top of the page (#4194) 2026-04-16 16:20:50 +02:00
Samy Pessé ead88591c2 Version Packages (#4193)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-16 14:19:37 +02:00
Peter White aa1fc246e2 Add support for sticky first column in tables (#4188) 2026-04-16 10:33:15 +00:00
Samy Pessé e792cffeb3 Version Packages (#4178)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2026-04-15 19:07:34 +02:00
Zeno Kapitein 119e3bc369 Refine Assistant phase UI (#4187) 2026-04-15 18:29:58 +02:00
Greg Bergé 823e7d5c4b Fix tags appearing on top of page even if disabled (#4192) 2026-04-15 18:26:06 +02:00
Claire Chabas f5f659e7fc Display updates blocks headings in page outline (#4167) 2026-04-15 16:20:51 +02:00
Brett Jephson 723e579958 Fix: whitespace of Expandable in Column (#4190) 2026-04-15 14:07:20 +01:00
conico974 f27901c453 Update deployment ID logic for environment-specific prefixes (#4191) 2026-04-15 14:52:44 +02:00
Zeno Kapitein 789c2fab60 Adjust empty TOC logic and layout (#4189) 2026-04-15 14:04:13 +02:00
conico974 3fa727c80b Migrate to Next 16 (#4162) 2026-04-15 13:20:25 +02:00
Zeno Kapitein 88c38fa505 Add Search tab to Docs Embed, refactor search into an embeddable frame (#4185) 2026-04-15 12:09:59 +02:00
Claire Chabas bda9fe2d76 Make ToC groups collapsible and other styles fixes/improvements (#4177) 2026-04-14 19:41:51 +02:00
Nolann B. 2bdade3b57 Fix HTML lang attribute not reflecting language variants (#4186) 2026-04-14 13:14:39 +02:00
Zeno Kapitein 8d2a95b168 Docs Embed: Better support light/dark mode overrides (#4181) 2026-04-14 10:50:05 +02:00
Taran Vohra 68c842bd4c Translations dropdown should show language spaces that best matches the current space title (#4184) 2026-04-14 13:11:55 +05:30
Nolann B. ecb4292551 Fix llms.txt and llms-full.txt language variants filtering (#4179) 2026-04-13 21:53:42 +02:00
Zeno Kapitein 7fd381c0b1 Hide empty sidebar on OpenAPI pages (#4180) 2026-04-13 20:29:57 +02:00
Zeno Kapitein 709964fe3e Rework page layout (#4013) 2026-04-13 14:48:24 +02:00
Samy Pessé afc7a38bef Version Packages (#4163)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 14:16:46 +02:00
Greg Bergé bbcfd72a6d Fix toolbar design (#4175) 2026-04-13 11:10:34 +00:00
spastorelli 8242f18b8a Extend gitbook/expr std lib with some additional methods (#4173)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2026-04-13 10:19:52 +00:00
Samy Pessé 4f7fba08df Prepare for step.content to be optional (#4174) 2026-04-13 11:47:21 +02:00
conico974 f17e292ba4 Refactor test setup for improved mock restoration (#4176) 2026-04-13 11:23:54 +02:00
Greg Bergé 6e1074ec4d Reduce search debounce (#4170) 2026-04-09 17:43:14 +02:00
Nolann B. c5e5857623 Exclude language variants from llms.txt and llms-full.txt (#4165) 2026-04-08 14:30:31 +02:00
Nolann B. 11df1fd395 Fix MCP getPage tool returning page not found (#4166) 2026-04-08 14:28:39 +02:00
162 changed files with 4023 additions and 3069 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve Assistant form controls
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add "Content-Signal" to "robots.txt"
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support variant selector in hidden section
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Improve AI agent detection and readability by outputting 200 for page not found.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix embeddable search links
@@ -64,6 +64,7 @@ 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,6 +19,9 @@ 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"
@@ -59,6 +62,17 @@ 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
@@ -66,6 +80,7 @@ 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,6 +40,7 @@ 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,9 +26,10 @@ jobs:
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
run: |
corepack enable npm
corepack install -g npm@latest
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -45,4 +46,3 @@ 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,6 +23,7 @@
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json",
"packages/gitbook/worker-configuration.d.ts",
"gitbook/tsconfig.json",
"**/*.css"
]
},
+280 -1514
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.30.0",
"turbo": "^2.9.2",
"turbo": "^2.9.6",
"vercel": "50.37.3"
},
"packageManager": "bun@1.3.7",
@@ -17,8 +17,9 @@
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2",
"axios": "1.8.4"
"esbuild": "0.27.3",
"axios": "1.8.4",
"@radix-ui/react-slot": "1.2.4"
},
"private": true,
"scripts": {
@@ -42,7 +43,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.175.0",
"@gitbook/api": "0.177.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -59,6 +60,7 @@
},
"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"
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch",
"next@16.2.3": "patches/next@16.2.3.patch"
}
}
+10
View File
@@ -1,5 +1,15 @@
# @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
+30 -9
View File
@@ -2,8 +2,9 @@
Embed your GitBook docs in your product or website.
The Docs Embed can contain two tabs:
The Docs Embed can contain three 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.
@@ -40,7 +41,7 @@ GitBook('configure', {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -72,6 +73,7 @@ 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
@@ -92,7 +94,7 @@ frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
actions: [
{
icon: 'circle-question',
@@ -122,11 +124,12 @@ 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', 'docs']}
tabs={['assistant', 'search', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
assistantName="Support Assistant"
suggestions={['What is GitBook?', 'How do I get started?']}
@@ -150,7 +153,7 @@ import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
// ...
}
```
@@ -178,7 +181,7 @@ function MyComponent() {
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
@@ -195,7 +198,7 @@ function MyComponent() {
**Client Factory:**
- `createGitBook(options: { siteURL: string })` → `GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })` → `string`
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` → `string`
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
**Frame Client:**
@@ -228,10 +231,10 @@ Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Type**: `('assistant' | 'search' | 'docs')[]`
```javascript
tabs: ['assistant', 'docs']
tabs: ['assistant', 'search', 'docs']
```
### `closeButton`
@@ -457,6 +460,24 @@ 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
+1 -1
View File
@@ -13,7 +13,7 @@
}
},
"sideEffects": false,
"version": "0.4.0",
"version": "0.5.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
@@ -35,4 +35,17 @@ 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,6 +8,12 @@ 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.
*/
@@ -42,6 +48,10 @@ 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', 'docs'],
tabs: ['assistant', 'search', 'docs'],
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
+1 -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')[];
tabs: ('assistant' | 'docs' | 'search')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
+7 -2
View File
@@ -19,12 +19,13 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) {
const {
className,
colorScheme,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
tabs = ['assistant', 'search', 'docs'],
trademark = true,
closeButton = false,
assistantName,
@@ -34,7 +35,10 @@ export function GitBookFrame(props: GitBookFrameProps) {
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
const frameURL = useMemo(
() => gitbook.getFrameURL({ visitor, colorScheme }),
[gitbook, visitor, colorScheme]
);
useEffect(() => {
if (frameRef.current) {
@@ -73,6 +77,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
width="100%"
height="100%"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+4 -1
View File
@@ -64,7 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
trademark: true,
};
@@ -101,6 +101,9 @@ function getIframe() {
widgetIframe?.remove();
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
+6
View File
@@ -1,5 +1,11 @@
# @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.4",
"version": "1.2.5",
"type": "module",
"exports": {
".": {
@@ -25,6 +25,7 @@ describe('autocomplete', () => {
description: 'An array of string',
items: SymbolString(),
});
const visitorClaimsKeyPropStringSymbol = SymbolString({ name: 'key' });
const symbols = {
visitor: SymbolObject({
name: 'visitor',
@@ -33,7 +34,7 @@ describe('autocomplete', () => {
name: 'claims',
description: 'The claims contained in the visitor JWT token',
properties: {
key: SymbolString({ name: 'key' }),
key: visitorClaimsKeyPropStringSymbol,
flags: SymbolObject({
name: 'flags',
properties: {
@@ -168,8 +169,12 @@ 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',
],
},
},
@@ -227,8 +232,12 @@ 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',
],
},
},
@@ -246,13 +255,47 @@ 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,12 +54,54 @@ 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,8 +255,12 @@ 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',
],
});
@@ -301,8 +305,12 @@ 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',
],
});
@@ -318,8 +326,12 @@ 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,6 +156,31 @@ 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
@@ -205,6 +230,39 @@ 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,5 +1,43 @@
# 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
+3 -1
View File
@@ -781,7 +781,9 @@ const testCases: TestsCase[] = [
run: async (page) => {
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
// Trademark exists by default
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
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".
// Go to another page with the customization query to disable the trademark
const pageBlocks = new URL(page.url());
+22 -2
View File
@@ -1,10 +1,27 @@
// @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: 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
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
@@ -18,10 +35,12 @@ 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.GITHUB_SHA ?? '').slice(0, 7),
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
@@ -40,6 +59,7 @@ 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": "2025-04-14",
"compatibility_date": "2026-04-02",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
@@ -1,7 +1,7 @@
{
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_date": "2026-04-02",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
+19 -14
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.23.2",
"version": "0.25.1",
"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.8.5",
"@opennextjs/cloudflare": "^1.14.4",
"@opennextjs/aws": "3.10.1",
"@opennextjs/cloudflare": "1.19.1",
"@panzoom/panzoom": "^4.6.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
@@ -28,6 +28,7 @@
"@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",
@@ -43,6 +44,7 @@
"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",
@@ -53,8 +55,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"leven": "^4.1.0",
"next": "15.4.11",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -62,8 +63,8 @@
"openapi-types": "^12.1.3",
"p-map": "^7.0.3",
"quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hotkeys-hook": "^4.4.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -98,8 +99,8 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/rison": "^0.0.9",
"@types/negotiator": "^0.6.4",
"bun-types": "catalog:",
@@ -112,17 +113,17 @@
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "50.37.3",
"wrangler": "^4.43.0",
"wrangler": "^4.79.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",
"build": "next build",
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
"build": "next build --webpack",
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
"start": "GITBOOK_URL=http://localhost:3000 next start",
"build:cloudflare": "opennextjs-cloudflare build",
"build:cloudflare": "GITBOOK_RUNTIME=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",
@@ -138,5 +139,9 @@
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
}
@@ -0,0 +1,36 @@
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,6 +5,7 @@ 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';
@@ -18,12 +19,14 @@ 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>
@@ -0,0 +1,16 @@
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} />;
}
@@ -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,54 +16,41 @@ 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 [context, { organization, site, shareKey }] = await Promise.all([
getServerActionBaseContext(),
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
const [context, siteURLData] = await Promise.all([
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
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([
(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;
})(),
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,
})
),
]);
const results = searchResults
.flatMap((resultItem): SearchResultGroup[] => {
.flatMap((resultItem) => {
if (resultItem.type === 'record') {
const result: OrderedComputedResult = {
type: 'record',
@@ -73,31 +60,25 @@ 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(context, {
items: transformSitePageResult({
asEmbeddable: Boolean(asEmbeddable),
linker: context.linker,
pageItem,
spaceItem: resultItem,
siteSpace: found?.siteSpace,
space: found?.siteSpace.space,
spaceURL: found?.siteSpace.urls.published,
siteSection: siteSection ?? undefined,
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
siteSection: found?.siteSection ?? undefined,
siteSectionGroup: found?.siteSectionGroup ?? undefined,
}),
}));
})
@@ -107,72 +88,103 @@ export async function POST(request: NextRequest) {
return NextResponse.json(results);
}
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;
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;
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: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
: linker.toPathInSpace(pageItem.path),
href: pageHref,
pageId: pageItem.id,
spaceId: spaceItem.id,
score: pageItem.score,
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),
breadcrumbs,
};
const pageSections =
pageItem.sections
?.filter((section) => section.title || section.body)
.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,
})) ?? [];
.map<ComputedSectionResult>((section) => {
let sectionHref = linker.toPathInSpace(pageItem.path);
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,
};
}) ?? [];
return [page, ...pageSections];
}
@@ -125,7 +125,7 @@ export async function GET(
'What can I ask you?',
'Show me tips and tricks',
],
tabs: ['assistant', 'docs'],
tabs: ['assistant', 'search', 'docs'],
closeButton: useCustomTrigger
});
@@ -0,0 +1,16 @@
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 -1
View File
@@ -119,7 +119,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
export 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);
revalidateTag(tag, { expire: 0 }); // Force revalidation without waiting for the next scheduled revalidation
});
return NextResponse.json({
@@ -1,6 +1,7 @@
import { CollapsibleContent } from '@/components/primitives';
import type { GitBookSiteContext } from '@/lib/context';
import { tcls } from '@/lib/tailwind';
import type { AIMessage } from '@gitbook/api';
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
import { DocumentView } from '../../DocumentView';
import { AIToolCallsSummary } from './AIToolCallsSummary';
import type { RenderAIMessageOptions } from './types';
@@ -17,31 +18,41 @@ export function AIMessageView(
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return message.steps.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex flex-col space-y-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 (
<div
<Tag
key={index}
className={tcls(
'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
'flex flex-col space-y-2 border-tint-subtle',
hasContent ? 'has-content' : ''
)}
>
<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"
/>
{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}
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
) : null}
</div>
</Tag>
);
})}
</div>
@@ -39,9 +39,7 @@ 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 = [
@@ -67,6 +65,16 @@ 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,5 +1,4 @@
'use server';
import { getEmbeddableLinker } from '@/lib/embeddable';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -36,10 +35,9 @@ export async function* streamAIChatResponse({
options?: RenderAIMessageOptions;
}) {
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
let context = await getServerActionBaseContext();
if (options?.asEmbeddable) {
context = { ...context, linker: getEmbeddableLinker(context.linker) };
}
const context = await getServerActionBaseContext({
isEmbeddable: options?.asEmbeddable,
});
const siteURLData = await getSiteURLDataFromMiddleware();
@@ -6,6 +6,8 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
AIMessageStepPhase,
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
@@ -24,8 +26,25 @@ 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.
@@ -208,6 +227,7 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
@@ -400,6 +420,11 @@ export function AIChatProvider(props: {
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
@@ -560,3 +585,78 @@ 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,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
type AIChatController,
type AIChatState,
getAIChatStatus,
useAI,
useAIChatController,
useAIChatState,
@@ -107,7 +108,7 @@ export function AIChat() {
/>
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody className="not-embed:px-4">
<EmbeddableFrameBody className="not-embed:px-0">
<AIChatBody
chatController={chatController}
chat={chat}
@@ -130,24 +131,13 @@ 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={
chat.error
? 'error'
: chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'working'
: 'thinking'
: chat.messages.length > 0
? chat.control
? 'confirm'
: 'done'
: 'default'
}
state={status}
/>
);
}
@@ -160,59 +150,45 @@ 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',
chat.loading || chat.control ? 'h-3 opacity-11' : 'h-0 opacity-0'
)}
className={tcls('relative', subtitleKey ? 'h-3 opacity-11' : 'h-0 opacity-0')}
>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
{(
[
['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
? 'animate-blur-in-display-slow'
: '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>
: 'animate-blur-out-display-slow'
)}
style={{
animationDelay: status === candidateStatus ? '.3s' : undefined,
}}
>
{subtitleKey ? t(language, key) : null}
</span>
))}
</EmbeddableFrameSubtitle>
);
}
@@ -250,7 +226,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 [scroll-behavior:smooth]"
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
orientation="vertical"
trailing={{ fade: false, button: true }}
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
@@ -298,7 +274,7 @@ export function AIChatBody(props: {
)}
</ScrollContainer>
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 pb-4">
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -4,7 +4,15 @@ import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
size?: number;
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
state?:
| 'default'
| 'intro'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
trademark?: boolean;
}
@@ -25,6 +33,7 @@ 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: '',
@@ -43,7 +52,7 @@ export function AIChatIcon({
viewBox="0 0 16 16"
preserveAspectRatio="xMaxYMid meet"
className={className}
aria-busy={state === 'thinking'}
aria-busy={['thinking', 'exploring', 'working'].includes(state)}
overflow="visible"
{...props}
fill="none"
@@ -63,6 +72,7 @@ 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: '',
@@ -124,6 +134,7 @@ export function AIChatIcon({
animation: {
intro: '',
thinking: '',
exploring: '',
working: '',
done: '',
confirm: 'bounceSmall 1s infinite both',
@@ -146,6 +157,7 @@ export function AIChatIcon({
animation: {
intro: 'fadeIn 2s forwards',
thinking: '',
exploring: '',
working: '',
done: '',
confirm: '',
@@ -186,6 +198,7 @@ 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: '',
@@ -201,7 +214,15 @@ export function AIChatIcon({
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
state?:
| 'default'
| 'intro'
| 'thinking'
| 'exploring'
| 'working'
| 'done'
| 'error'
| 'confirm';
}
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
@@ -213,6 +234,7 @@ 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: '',
@@ -234,6 +256,7 @@ 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,8 +1,16 @@
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api';
import type { AIChatController, AIChatState } from '../AI';
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 { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
@@ -11,10 +19,11 @@ 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 Message = (typeof chat.messages)[0];
type MessageGroup = { message: Message; originalIndex: number };
type MessageGroup = { message: AIChatMessage; originalIndex: number };
const messageGroups: Array<Array<MessageGroup>> = [];
let currentGroup: Array<MessageGroup> = [];
@@ -36,6 +45,8 @@ export function AIChatMessages(props: {
messageGroups.push(currentGroup);
}
const language = useLanguage();
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
return (
@@ -52,8 +63,16 @@ 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 (
<div
<Collapsible
open={!hasFinalAnswer}
disabled={!hasFinalAnswer}
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
@@ -62,12 +81,12 @@ export function AIChatMessages(props: {
}
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-6',
'flex flex-col gap-2',
'break-words',
'group/message',
'animate-blur-in-slow',
message.role === AIMessageRole.User
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
? 'mb-4 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'
@@ -77,13 +96,45 @@ 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(
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
chat.loading ? '' : 'hidden opacity-0'
'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'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
@@ -101,7 +152,7 @@ export function AIChatMessages(props: {
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
className="-ml-1.5 -mt-4 mb-2"
/>
) : null}
<AIChatFollowupSuggestions
@@ -110,7 +161,7 @@ export function AIChatMessages(props: {
/>
</>
) : null}
</div>
</Collapsible>
);
})}
</div>
@@ -30,7 +30,7 @@ export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
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"
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
This toolbar appears only when signed in to GitBook.
@@ -2,6 +2,7 @@ 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';
@@ -63,7 +64,7 @@ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
'columns',
'code',
'content-ref',
'hint',
'divider',
];
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
@@ -89,12 +90,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`}
block={node}
style={[
'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',
'decoration-primary/6',
FULL_WIDTH_BLOCKS.includes(node.type) ? CONTENT_STYLE : CONTENT_STYLE_REDUCED,
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props;
return <hr className={tcls(style, 'page-width-wide:max-w-full border-tint-subtle')} />;
return <hr className={tcls(style, '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-screen-xl'
? 'max-w-6xl'
: 'max-w-3xl',
blockStyle,
]}
@@ -56,12 +56,13 @@ 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('mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8', style)}>
<div className={tcls('flex flex-row gap-4 md:gap-8', style)}>
<div className="relative select-none">
<div
className={tcls(
@@ -15,11 +15,13 @@ 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(
'flex',
'group/row flex',
'border-tint-subtle',
'transition-colors',
'hover:bg-tint-hover'
@@ -33,6 +35,7 @@ export function RecordRow(
autoSizedColumns,
fixedColumns,
});
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
@@ -42,7 +45,10 @@ 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'
'border-tint-subtle',
isStickyFirstColumnCell
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
: undefined
)}
style={{
width: columnWidth,
@@ -5,12 +5,42 @@ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from
interface StickyViewGridProps {
className?: string;
header: ReactNode;
header?: ReactNode;
stickyHeader?: boolean;
tableClassName?: string;
children: ReactNode;
}
export function StickyViewGrid({
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({
className,
header,
tableClassName,
@@ -99,48 +129,46 @@ export function StickyViewGrid({
};
}, [syncStickyLayout]);
const resolvedTableClassName = tableClassName ?? 'w-fit';
return (
<div className={className}>
<div
ref={rootRef}
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'
)}
className="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"
>
<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))]'
)}
>
{header ? (
<div
ref={stickyHeaderRef}
className={tcls(
'flex',
'flex-col',
tableClassName ?? 'w-fit',
'[transform:translateX(var(--table-sticky-scroll-left,0px))]'
'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))]'
)}
style={{ width: 'var(--table-sticky-table-width)' }}
>
{header}
<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>
</div>
</div>
) : null}
<div
ref={bodyScrollRef}
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'
)}
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"
>
<div
ref={bodyTableRef}
className={tcls('flex', 'flex-col', tableClassName ?? 'w-fit')}
className={tcls('flex', 'flex-col', resolvedTableClassName)}
>
{children}
</div>
@@ -53,25 +53,33 @@ 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) {
if (withStickyHeader || withStickyFirstColumn) {
return (
<StickyViewGrid
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
stickyHeader={withStickyHeader}
tableClassName={tableContainerClassName}
header={
<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'
)}
/>
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
}
>
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
@@ -23,6 +23,9 @@ 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
@@ -30,6 +33,7 @@ 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
)}
>
@@ -45,7 +49,12 @@ export function ViewGridHeader(props: ViewGridHeaderProps) {
key={column}
role="columnheader"
className={tcls(
'px-3 py-2 font-medium text-sm text-tint-strong',
'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,
getColumnAlignment(definition)
)}
style={{
@@ -56,6 +65,12 @@ 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, { use, useMemo } from 'react';
import React from 'react';
import { useTrackEvent } from '../Insights';
import { LinkContext, type LinkContextType } from '../primitives';
import { LinkContext } from '../primitives';
import {
EmbeddableFrame,
EmbeddableFrameBody,
@@ -27,7 +27,7 @@ import {
EmbeddableIframeButtons,
EmbeddableIframeCloseButton,
EmbeddableIframeTabs,
useEmbeddableConfiguration,
useEmbeddableLinkContext,
} from './EmbeddableIframeAPI';
type EmbeddableAIChatProps = {
@@ -43,7 +43,6 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const chat = useAIChatState();
const { config: siteConfig } = useAI();
const chatController = useAIChatController();
const embedConfig = useEmbeddableConfiguration();
const language = useLanguage();
React.useEffect(() => {
@@ -65,21 +64,8 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
}, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
const hasDocsTab = embedConfig.tabs.includes('docs');
const trademark = siteConfig.trademark;
const currentLinkContext = use(LinkContext);
const linkContext: LinkContextType = useMemo(
() =>
hasDocsTab
? { ...currentLinkContext, externalTarget: '_blank' }
: {
...currentLinkContext,
isExternalClient: () => true,
isExternalServer: () => true,
externalTarget: '_blank',
},
[hasDocsTab, currentLinkContext]
);
const { linkContext } = useEmbeddableLinkContext();
return (
<EmbeddableFrame>
@@ -58,10 +58,13 @@ export async function EmbeddableDocsPage(
<EmbeddableIframeButtons />
<EmbeddableIframeCloseButton />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain data-testid="embed-docs-page">
<EmbeddableFrameMain
data-testid="embed-docs-page"
className={variants.generic.length > 1 ? 'has-sidebar' : 'no-sidebar'}
>
<div className="relative flex flex-col border-tint-subtle border-b theme-bold:bg-header-background">
<EmbeddableFrameHeader className="theme-bold:text-header-link">
<HeaderMobileMenu className="-ml-2 page-no-toc:hidden theme-bold:text-header-link hover:theme-bold:bg-header-link/3 hover:theme-bold:text-header-link lg:hidden" />
<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" />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>{context.site.title}</EmbeddableFrameTitle>
</EmbeddableFrameHeaderMain>
@@ -79,7 +82,9 @@ export async function EmbeddableDocsPage(
{context.sections ? (
<SiteSectionTabs
className="not-theme-bold:-mt-2 theme-bold:bg-tint-base"
sections={encodeClientSiteSections(context, context.sections)}
sections={encodeClientSiteSections(context, context.sections, {
asEmbeddable: true,
})}
>
{variants.translations.length > 1 ? (
<TranslationsDropdown
@@ -105,6 +110,7 @@ export async function EmbeddableDocsPage(
trailing={{ fade: false, button: true }}
>
<TableOfContents
className="layout-wide:no-sidebar:lg:hidden!"
context={context}
withTrademark={false}
header={
@@ -30,11 +30,13 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
}
);
export function EmbeddableFrameMain(props: React.ComponentProps<'div'>) {
const { children, ...rest } = props;
export function EmbeddableFrameMain(
props: React.ComponentProps<'div'> & { className?: ClassValue }
) {
const { children, className, ...rest } = props;
return (
<div className="flex flex-1 flex-col overflow-hidden" {...rest}>
<div className={tcls('flex flex-1 flex-col overflow-hidden', className)} {...rest}>
{children}
</div>
);
@@ -4,11 +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 { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives';
import { Button, LinkContext, type LinkContextType } from '../primitives';
import { getChannel } from './channel';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
@@ -105,6 +106,31 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
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 };
}
/**
* Display the buttons defined by the parent window.
*/
@@ -151,55 +177,59 @@ export function EmbeddableIframeTabs(props: {
siteTitle: string;
}) {
const { ref, active = 'assistant', baseURL, siteTitle } = props;
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
const actions = useEmbeddableConfiguration((state) => state.actions);
const tabs = useEmbeddableTabs();
const { assistants, config } = useAI();
const language = useLanguage();
const router = useRouter();
const tabs = [
const enabledTabs = [
config.aiMode === CustomizationAIMode.Assistant &&
assistants[0] &&
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
tabs.includes('assistant')
? {
key: 'assistant',
label: assistants[0].label,
icon: assistants[0].icon,
onClick: () => {
router.push(`${baseURL}/assistant`);
},
href: `${baseURL}/assistant`,
}
: null,
configuredTabs.includes('docs') || configuredTabs.length === 0
tabs.includes('search')
? {
key: 'search',
label: tString(language, 'search'),
icon: 'search',
href: `${baseURL}/search`,
}
: null,
tabs.includes('docs')
? {
key: 'docs',
label: siteTitle,
icon: 'book-open',
onClick: () => {
router.push(`${baseURL}/page/`);
},
href: `${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(() => {
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
if (enabledTabs.length === 0) {
return;
}
if (active === 'assistant' && !hasAssistant) {
router.replace(`${baseURL}/page`);
} else if (active === 'docs' && !hasDocs) {
router.replace(`${baseURL}/assistant`);
}
}, [tabs, baseURL, router, active]);
return tabs.length > 1 || actions.length > 0 ? (
const activeTab = enabledTabs.find((tab) => tab.key === active);
const fallbackTab = enabledTabs.at(0);
if (!activeTab && fallbackTab) {
router.replace(fallbackTab.href);
}
}, [enabledTabs, router, active]);
return enabledTabs.length > 1 || actions.length > 0 ? (
<div className="flex flex-col items-center gap-2" ref={ref}>
{tabs.map((tab) => (
{enabledTabs.map((tab) => (
<Button
key={tab.key}
data-testid={`embed-tab-${tab.key}`}
@@ -210,7 +240,9 @@ export function EmbeddableIframeTabs(props: {
active={tab.key === active}
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={tab.onClick}
onClick={() => {
router.push(tab.href);
}}
tooltipProps={{
contentProps: {
side: 'right',
@@ -6,6 +6,8 @@ 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';
@@ -18,6 +20,7 @@ type EmbeddableRootLayoutProps = {
context: GitBookSiteContext;
withTracking: boolean;
visitorAuthClaims: VisitorAuthClaims;
forcedTheme?: CustomizationDefaultThemeMode | null;
};
/**
@@ -27,17 +30,22 @@ export async function EmbeddableRootLayout({
context,
withTracking,
visitorAuthClaims,
forcedTheme,
children,
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
const theme = resolveEmbeddableTheme(context.customization, forcedTheme);
return (
<CustomizationRootLayout context={context} htmlClassName="embed">
<CustomizationRootLayout
context={context}
htmlClassName="embed"
forcedTheme={theme.htmlTheme}
>
<SiteLayoutClientContexts
forcedTheme={
context.customization.themes.toggeable
? undefined
: context.customization.themes.default
}
defaultTheme={context.customization.themes.default}
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}`}
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
@@ -0,0 +1,109 @@
'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}
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"
>
<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>
);
}
@@ -0,0 +1,22 @@
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,3 +2,4 @@ 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 } from '../layout';
import { CONTAINER_STYLE, CONTENT_STYLE } from '../layout';
import { FooterLinksGroup } from './FooterLinksGroup';
import { SocialAccountButton } from './SocialAccounts';
@@ -48,16 +48,11 @@ export function Footer(props: { context: GitBookSiteContext }) {
>
<div
className={tcls(
'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',
CONTENT_STYLE,
'flex @xs:grid @4xl:max-w-none! 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]',
'@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]'
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]'
)}
>
{
@@ -113,12 +108,8 @@ export function Footer(props: { context: GitBookSiteContext }) {
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<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">
<div className={tcls('col-span-2')}>
<div className="mx-auto flex @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 } from '../Search';
import { SearchContainer, getSearchBaseProps } from '../Search';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { HeaderLink } from './HeaderLink';
import { HeaderLinkMore } from './HeaderLinkMore';
@@ -26,7 +26,8 @@ export function Header(props: {
};
}) {
const { context, withTopHeader, variants } = props;
const { siteSpace, visibleSiteSpaces, visibleSections, customization } = context;
const { siteSpace, visibleSections, customization } = context;
const searchProps = getSearchBaseProps(context);
const withSections = Boolean(
visibleSections &&
@@ -110,7 +111,7 @@ export function Header(props: {
'hover:site-header:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'page-no-toc:hidden lg:hidden'
: 'no-sidebar:hidden lg:hidden'
)}
/>
<HeaderLogo context={context} />
@@ -143,27 +144,9 @@ export function Header(props: {
)}
>
<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,29 +1,16 @@
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 { 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';
import { SiteAuthLoginDropdownMenuItem } from '../SiteAuth/SiteAuthLoginLink';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { HeaderLinkDropdown, HeaderLinkNavItem } from './HeaderLinkDropdown';
export async function HeaderLink(props: {
context: GitBookSiteContext;
@@ -39,29 +26,22 @@ export async function HeaderLink(props: {
if (link.links && link.links.length > 0) {
return (
<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)}
/>
)
<HeaderLinkDropdown
headerPreset={headerPreset}
title={title}
hasTarget={!!target}
linkTarget={link.to}
linkStyle={linkStyle}
href={target?.href}
isSiteAuthLoginHref={
target ? isSiteAuthLoginHref(context.linker, target.href) : false
}
openOnHover={true}
dropdownClassName={`shrink ${customization.styling.search === 'prominent' ? 'right-0 left-auto' : null}`}
>
{link.links.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
))}
</DropdownMenu>
</HeaderLinkDropdown>
);
}
@@ -82,148 +62,6 @@ 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;
@@ -0,0 +1,216 @@
'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,7 +6,6 @@ import {
SiteInsightsLinkPosition,
type SiteSocialAccount,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import type React from 'react';
import { resolveContentRef } from '@/lib/references';
@@ -15,13 +14,12 @@ 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';
/**
@@ -35,32 +33,11 @@ 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`}>
<DropdownMenu
button={renderButton}
openOnHover={true}
className={tcls(
<HeaderLinkMoreDropdown
label={label}
dropdownClassName={tcls(
'max-md:right-0 max-md:left-auto',
context.customization.styling.search === 'prominent' && 'right-0 left-auto'
)}
@@ -75,7 +52,7 @@ export function HeaderLinkMore(props: {
account={account}
/>
))}
</DropdownMenu>
</HeaderLinkMoreDropdown>
</div>
);
}
@@ -0,0 +1,48 @@
'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>
);
}
@@ -1,12 +1,11 @@
import type { SiteSpace } from '@gitbook/api';
import { useMemo } from 'react';
import type { IconName } from '@gitbook/icons';
import type { GitBookSiteContext } from '@/lib/context';
import { getLocalizedTitle, getSiteSpaceURL } from '@/lib/sites';
import { tcls } from '@/lib/tailwind';
import { Button, type ButtonProps, ToggleChevron } from '../primitives';
import { DropdownMenu } from '../primitives/DropdownMenu';
import { SpacesDropdownMenuItems } from './SpacesDropdownMenuItem';
import type { ButtonProps } from '../primitives';
import { SpacesDropdownClient } from './SpacesDropdownClient';
// Memoized regex for checking if a string starts with an emoji
const EMOJI_REGEX = /^\p{Emoji}/u;
@@ -21,43 +20,34 @@ export function SpacesDropdown(props: {
siteSpaces: SiteSpace[];
className?: string;
variant?: ButtonProps['variant'];
icon?: ButtonProps['icon'];
icon?: IconName;
}) {
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 (
<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>
<SpacesDropdownClient
title={getLocalizedTitle(siteSpace, currentLanguage)}
icon={icon}
variant={variant}
className={className}
dropdownClassName={dropdownClassName}
slimSpaces={slimSpaces}
curPath={siteSpace.path}
/>
);
}
@@ -69,9 +59,8 @@ 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 = useMemo(() => startsWithEmoji(title), [title]);
const hasEmojiPrefix = startsWithEmoji(title);
return (
<SpacesDropdown
@@ -0,0 +1,50 @@
'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,21 +1,23 @@
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 { getSpaceLanguage, t } from '@/intl/server';
import { getDocumentSections } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
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.
@@ -30,46 +32,20 @@ export function PageAside(props: {
}) {
const { page, document, withPageFeedback, context } = props;
const { customization, site } = context;
const language = getSpaceLanguage(context);
return (
<aside
<SideSheet
side="right"
toggleClass="outline-open"
withOverlay
withCloseButton
className={tcls(
'group/aside',
'order-last',
'hidden',
'max-w-0',
'pt-8',
'pb-4',
'opacity-0',
'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',
'xl:sticky',
'w-64',
// Without header
'lg:top-0',
@@ -87,80 +63,68 @@ export function PageAside(props: {
'lg:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:[html[style*="--outline-height"]_&]:max-h-(--outline-height)!',
// 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'
'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'
)}
>
<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} />
<div className="flex h-full w-full shrink-0 flex-col overflow-hidden">
{page.layout.outline ? (
<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="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>
</>
) : null}
{customization.themes.toggeable || site.ads ? (
<PageAsideFooter context={context} />
) : null}
</div>
</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>
</SideSheet>
);
}
@@ -170,7 +134,7 @@ async function PageAsideSections(props: { document: JSONDocument; context: GitBo
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? (
<div className="overflow-y-auto">
<div data-gb-page-outline className="overflow-y-auto">
<ScrollSectionsList sections={sections} />
</div>
) : null;
@@ -187,7 +151,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 xl:max-2xl:page-api-block:p-5',
'sidebar-list-default:px-3 pt-5 first:pt-0',
'empty:hidden'
)}
>
@@ -208,8 +172,7 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col',
'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',
'border-tint-subtle',
'pt-4'
)}
>
@@ -0,0 +1,60 @@
'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 xl:max-2xl:page-api-block:mt-0 xl:max-2xl:page-api-block:p-2"
className="relative flex flex-col border-tint-subtle sidebar-list-line:border-l pb-5"
ref={scrollContainerRef}
>
{sections.map((section) => (
@@ -0,0 +1,18 @@
'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,6 +10,7 @@ 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';
@@ -40,8 +41,6 @@ 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')
@@ -55,14 +54,21 @@ export function PageBody(props: {
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageWidthWide = page.layout.width === 'wide';
const siteWidthWide = pageWidthWide || contentFullWidth;
// 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 language = getSpaceLanguage(context);
const updatedAt = page.updatedAt ?? page.createdAt;
const hasVisibleTOCItems =
context.revision.pages.filter(
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
(page) =>
page.type === 'link' ||
page.type === 'computed' ||
(page.type === 'group' && !page.hidden) ||
(page.type === 'document' && !page.hidden)
).length > 0;
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
@@ -72,16 +78,16 @@ export function PageBody(props: {
<main
className={tcls(
'relative min-w-0 flex-1',
'max-w-screen-2xl py-8',
// Allow words to break if they are too long.
'break-anywhere',
'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
'@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
pageHasToc ? 'page-has-toc' : 'page-no-toc'
CONTENT_STYLE,
pageHasToc ? 'page-has-toc' : 'page-no-toc',
wideLayout ? 'layout-wide' : 'layout-default'
)}
>
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
<PreservePageLayout wideLayout={wideLayout} pageHasToc={pageHasToc} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -95,18 +101,12 @@ export function PageBody(props: {
{document && !isNodeEmpty(document) ? (
<OptionalSuspense
staticRoute={staticRoute}
fallback={
<DocumentViewSkeleton
document={document}
blockStyle="page-api-block:ml-0"
/>
}
fallback={<DocumentViewSkeleton document={document} blockStyle="" />}
>
<SuspenseLoadedHint />
<DocumentView
document={document}
style="flex flex-col [&>*+*]:mt-5"
blockStyle="page-api-block:ml-0"
style="clear-both flex flex-col [&>*+*]:mt-5"
context={{
mode: 'default',
contentContext: {
@@ -125,28 +125,35 @@ export function PageBody(props: {
<PageFooterNavigation context={context} page={page} />
) : 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
}
{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}
</main>
<TrackPageViewEvent displayContext={insightsDisplayContext} />
@@ -10,6 +10,7 @@ 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.
@@ -76,15 +77,12 @@ 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:page-width-wide:grid-cols-3'
'md:layout-wide:grid-cols-3'
)}
>
{pageElements}
@@ -7,6 +7,7 @@ import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { assert } from 'ts-essentials';
import { CONTENT_STYLE } from '../layout';
import { PageCoverImage } from './PageCoverImage';
import { getCoverHeight } from './coverHeight';
import defaultPageCoverSVG from './default-page-cover.svg';
@@ -22,7 +23,7 @@ export async function PageCover(props: {
cover: RevisionPageDocumentCover;
context: GitBookSiteContext;
}) {
const { as, page, cover, context } = props;
const { as, cover, context } = props;
const height = getCoverHeight(cover);
const [resolved, resolvedDark] = await Promise.all([
@@ -85,36 +86,37 @@ export async function PageCover(props: {
'overflow-hidden',
// Negative margin to balance the container padding
'-mx-4',
'transition-all',
'motion-reduce:transition-none',
// Full-width cover: extend to edges, disregard TOC where possible
as === 'full'
? [
'sm:-mx-6',
'md:-mx-8',
!page.layout.tableOfContents &&
context.customization.header.preset !== 'none'
? [
'xl:-ml-76',
// Round the bottom corners once the page is wider than the image
'2xl:circular-corners:rounded-b-3xl 2xl:rounded-corners:rounded-b-xl',
]
: [
'lg:-ml-12',
// Round the bottom left corner once the sidebar is shown next to it
'lg:rounded-corners:rounded-bl-xl',
'lg:circular-corners:rounded-bl-3xl',
// Round the bottom right corner once the page is wider than the image
'2xl:rounded-corners:rounded-br-xl',
'2xl:circular-corners:rounded-br-3xl',
],
'lg:-ml-12',
// Extend the full-width cover
'layout-default:page-no-toc:lg:-ml-92', // Extend into the left sidebar if there's no TOC...
'layout-wide:2xl:-mr-[clamp(2rem,calc((100vw-90rem)/2+2rem),18rem)]', // ...and to the right if there's no outline.
'layout-wide:page-no-toc:2xl:-mx-[max(calc((100vw-90rem)/2+2rem),2rem)]', // Span full width if the page content is centered.
'layout-wide:has-sidebar:page-no-toc:lg:-ml-[max(calc((100vw-90rem)/2+23rem),23rem)]', // If there's still a sidebar, we have to factor it in too.
// Corner rounding: we round once the page is wide enough to have space around the cover.
'layout-default:2xl:rounded-corners:rounded-b-xl',
'layout-default:2xl:circular-corners:rounded-b-3xl',
'layout-wide:3xl:circular-corners:rounded-b-3xl',
'layout-wide:3xl:rounded-corners:rounded-b-xl',
// Round the bottom left corner once the sidebar is shown next to it
'has-sidebar:lg:rounded-corners:rounded-bl-xl',
'has-sidebar:lg:circular-corners:rounded-bl-3xl',
]
: [
'sm:mx-auto',
'max-w-3xl ',
'page-width-wide:max-w-screen-2xl',
// Regular cover: size regularly along with other content
CONTENT_STYLE,
'max-sm:-mx-4',
'sm:rounded-corners:rounded-xl',
'sm:circular-corners:rounded-3xl',
'mb-8',
'max-sm:w-screen',
'max-sm:-mt-8',
]
)}
@@ -7,6 +7,7 @@ import { resolvePrevNextPages } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import type { GitBookSiteContext } from '@/lib/context';
import { CONTENT_STYLE } from '../layout';
import { Link, type LinkInsightsProps } from '../primitives';
/**
@@ -28,14 +29,12 @@ export async function PageFooterNavigation(props: {
return (
<div
className={tcls(
CONTENT_STYLE,
'flex',
'flex-col',
'md:flex-row',
'mt-6',
'gap-2',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
'mx-auto',
'text-tint'
)}
>
@@ -10,7 +10,9 @@ import {
PageActionsDropdown,
type PageActionsDropdownURLs,
} from '../PageActions/PageActionsDropdown';
import { PageAsideToggleButton } from '../PageAside/PageAsideButton';
import { PageIcon } from '../PageIcon';
import { CONTENT_STYLE, CONTENT_STYLE_REDUCED } from '../layout';
import { StyledLink } from '../primitives';
import { PageTags } from './PageTags';
@@ -33,77 +35,56 @@ export async function PageHeader(props: {
withRSSFeed,
].some(Boolean);
// When title and description are hidden,
// only display the page actions if the page contains an update block.
if (!page.layout.title && !page.layout.description) {
if (!hasPageActions) {
return null;
}
return (
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
className="absolute top-8 right-0 page-updates-block:flex hidden"
/>
);
if (!page.layout.title && !page.layout.description && !hasPageActions) {
return null;
}
return (
<header
className={tcls(
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
'mx-auto',
'mb-6',
'space-y-3',
'page-api-block:ml-0',
'page-api-block:max-w-full',
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
)}
>
{hasPageActions ? (
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
className={tcls(
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
hasAncestors ? '-my-1.5' : '-mt-3 xs:mt-2'
)}
/>
) : null}
<header className={tcls(CONTENT_STYLE, 'mb-6', 'space-y-3')}>
<div
className={tcls(
'float-right ml-4 flex gap-2',
hasAncestors ? '-my-0.5' : '-mt-3 xs:mt-2'
)}
>
{hasPageActions ? (
<PageActionsDropdown
siteTitle={context.site.title}
urls={getPageActionsURLs({ context, page, withRSSFeed })}
actions={context.customization.pageActions}
/>
) : null}
<PageAsideToggleButton />
</div>
{hasAncestors && (
<nav aria-label="Breadcrumb">
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2', 'text-tint')}>
<nav aria-label="Breadcrumb" className="text-tint leading-snug">
<ol className="inline">
{ancestors.map((breadcrumb, index) => {
const href = linker.toPathForPage({
pages: revision.pages,
page: breadcrumb,
});
return (
<li key={breadcrumb.id} className="flex items-center gap-2">
<li key={breadcrumb.id} className="inline">
<StyledLink
href={href}
className={tcls(
'inline',
'no-underline',
'hover:underline',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex',
'items-center',
'gap-1.5',
'contrast-more:underline',
'contrast-more:decoration-current'
)}
>
<PageIcon
page={breadcrumb}
style="flex size-4 items-center justify-center text-base leading-none"
style="mr-1 inline size-3.5 shrink-0"
/>
{breadcrumb.title}
</StyledLink>
@@ -111,7 +92,7 @@ export async function PageHeader(props: {
<Icon
aria-hidden
icon="chevron-right"
className="size-3 text-tint-subtle"
className="mx-2 inline-flex size-2 text-tint-subtle"
/>
)}
</li>
@@ -143,7 +124,9 @@ export async function PageHeader(props: {
</h1>
) : null}
{page.description && page.layout.description ? (
<p className={tcls('text-lg', 'text-tint', 'clear-both')}>{page.description}</p>
<p className={tcls(CONTENT_STYLE_REDUCED, 'text-lg', 'text-tint', 'clear-both')}>
{page.description}
</p>
) : null}
</header>
);
@@ -8,8 +8,7 @@ import { Tag } from '../Tag';
export function PageTags(props: { page: RevisionPageDocument; revision: Revision }) {
const { page, revision } = props;
const pageTags = page.tags; // TODO: simplify once new API lands as `tags` will always be defined.
if (!pageTags || pageTags.length === 0) {
if (!page.layout.tags || page.tags.length === 0) {
return null;
}
@@ -17,7 +16,7 @@ export function PageTags(props: { page: RevisionPageDocument; revision: Revision
return (
<div className="flex flex-wrap gap-1.5">
{pageTags.map((tagRef) => {
{page.tags.map((tagRef) => {
const resolved = resolveTag(tagRef.tag.tag, revisionTags);
if (!resolved) {
return null;
@@ -3,18 +3,18 @@ import * as React from 'react';
/**
* This component preserves the layout of the page while loading a new one.
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
* This approach is needed as page layout is done using CSS (`body:has(.layout-wide)`),
* which becomes false while transitioning between the 2 page states:
*
* 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
* 1. Page 1 with wide layout: `body:has(.layout-wide)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.layout-wide)` is false
* 3. Page 2 with wide layout: `body:has(.layout-wide)` is true
*
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
* It also preserves the page TOC state (page-has-toc/page-no-toc) to prevent logo sizing issues during navigation.
*/
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
const { siteWidthWide, pageHasToc } = props;
export function PreservePageLayout(props: { wideLayout: boolean; pageHasToc: boolean }) {
const { wideLayout, pageHasToc } = props;
React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions
@@ -24,10 +24,12 @@ export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc:
return;
}
if (siteWidthWide) {
header.classList.add('site-width-wide');
if (wideLayout) {
header.classList.add('layout-wide');
header.classList.remove('layout-default');
} else {
header.classList.remove('site-width-wide');
header.classList.remove('layout-wide');
header.classList.add('layout-default');
}
if (pageHasToc) {
@@ -37,7 +39,7 @@ export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc:
header.classList.add('page-no-toc');
header.classList.remove('page-has-toc');
}
}, [siteWidthWide, pageHasToc]);
}, [wideLayout, pageHasToc]);
return null;
}
@@ -23,7 +23,7 @@ import * as ReactDOM from 'react-dom';
import { type FontData, getFontData } from '@/fonts';
import { fontNotoColorEmoji, fonts } from '@/fonts/default';
import { getSpaceLanguage, getSpaceLocale } from '@/intl/server';
import { getContentLocale, getSpaceLanguage } from '@/intl/server';
import { getAssetURL } from '@/lib/assets';
import { tcls } from '@/lib/tailwind';
@@ -69,7 +69,7 @@ export async function CustomizationRootLayout(props: {
const customization =
'customization' in context ? context.customization : defaultCustomization();
const locale = getSpaceLocale(context);
const locale = getContentLocale(context);
const language = getSpaceLanguage(context);
const tintColor = getTintColor(customization);
const mixColor = getTintMixColor(customization.styling.primaryColor, tintColor);
@@ -198,6 +198,7 @@
:root {
@apply leading-relaxed;
interpolate-size: allow-keywords; /* Opt-in for modern browsers to interpolate "auto" values in transitions/animations. */
overflow-x: hidden; /* We never want horizontal scroll of the whole page, it looks buggy and we should never have overflow anyway */
}
/* Modern browsers with `scrollbar-*` support */
@@ -31,8 +31,8 @@ export type SearchAskState =
/**
* Fetch and render the answers to a question.
*/
export function SearchAskAnswer(props: { query: string }) {
const { query } = props;
export function SearchAskAnswer(props: { query: string; asEmbeddable?: boolean }) {
const { query, asEmbeddable } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
@@ -49,7 +49,7 @@ export function SearchAskAnswer(props: { query: string }) {
query,
});
const { stream } = await streamAskQuestion({ question: query });
const { stream } = await streamAskQuestion({ question: query, asEmbeddable });
for await (const chunk of readStreamableValue(stream)) {
if (cancelled) {
return;
@@ -74,7 +74,7 @@ export function SearchAskAnswer(props: { query: string }) {
cancelled = true;
}
};
}, [query, setAskState, trackEvent]);
}, [asEmbeddable, query, setAskState, trackEvent]);
React.useEffect(() => {
return () => {
@@ -167,15 +167,18 @@ function AnswerBody(props: { query: string; answer: AskAnswerResult }) {
<>
<div data-testid="search-ask-answer" className="animate-fade-in-slow text-tint-strong">
{answer.body ?? t(language, 'search_ask_no_answer')}
{answer.sources.length > 0 ? (
{answer.error ? (
<div className="mt-4 text-sm text-tint">{t(language, 'search_ask_error')}</div>
) : null}
{!answer.error && answer.sources.length > 0 ? (
// @TODO: Add responseId once search uses new AI endpoint
<AIResponseFeedback query={query} className="-ml-1 mt-2" responseId="" />
) : null}
{answer.followupQuestions.length > 0 ? (
{!answer.error && answer.followupQuestions.length > 0 ? (
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
) : null}
</div>
{answer.sources.length > 0 ? (
{!answer.error && answer.sources.length > 0 ? (
<AnswerSources
sources={answer.sources}
language={language}
@@ -1,123 +1,63 @@
'use client';
import { t, useLanguage } from '@/intl/client';
import { getLocalizedTitle } from '@/lib/sites';
import { CustomizationSearchStyle, type SiteSection, type SiteSpace } from '@gitbook/api';
import { useRouter } from 'next/navigation';
import { CustomizationSearchStyle } from '@gitbook/api';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useAI } from '../AI';
import { AIChatButton } from '../AIChat';
import { useTrackEvent } from '../Insights';
import { useIsMobile } from '../hooks/useIsMobile';
import { Popover, useBodyLoaded } from '../primitives';
import { SearchAskAnswer } from './SearchAskAnswer';
import { useSearchAskState } from './SearchAskContext';
import { SearchAskProvider } from './SearchAskContext';
import { Popover } from '../primitives';
import { SearchFrame } from './SearchFrame';
import { SearchInput } from './SearchInput';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
interface SearchContainerProps {
/** The current site space. */
siteSpace: SiteSpace;
/** All site spaces in the current section. */
siteSpaces: ReadonlyArray<SiteSpace>;
/** Whether there are sections on the site. */
withSections: boolean;
/** The current section, displayed in search scope toggle. */
section?: Pick<SiteSection, 'title' | 'icon'>;
/** Whether the current section has variants. */
withVariants: boolean;
/** Whether any section on the site has variants. */
withSiteVariants: boolean;
import type { SearchBaseProps } from './search-props';
import { useSearchController } from './useSearchController';
interface SearchContainerProps extends SearchBaseProps {
style: CustomizationSearchStyle;
className?: string;
viewport?: 'desktop' | 'mobile';
/** URL for the search API route, e.g. from linker.toPathInSpace('~gitbook/search'). */
searchURL: string;
}
/**
* Client component to render the search input and results.
*/
export function SearchContainer({
siteSpace,
section,
withVariants,
withSiteVariants,
withSections,
style,
className,
viewport,
siteSpaces,
searchURL,
...searchProps
}: SearchContainerProps) {
const { assistants, config } = useAI();
const state = useSearchState();
const setSearchState = useSetSearchState();
const searchAsk = useSearchAskState();
const router = useRouter();
const trackEvent = useTrackEvent();
const resultsRef = useRef<SearchResultsRef>(null);
const searchInputRef = useRef<HTMLDivElement>(null);
const isLoaded = useBodyLoaded();
const isMobile = useIsMobile();
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
// Handle initial ask state on page load, once assistants are ready
const initialRef = React.useRef(state?.ask === undefined || state?.ask === null); // If ask is not set on page load, we will never trigger
React.useEffect(() => {
if (initialRef.current) return;
if (assistants.length === 0) return;
if (state?.ask === undefined || state?.ask === null) return;
// For simplicity we're only triggering the first assistant
// Because this is in the layout, we need to await for the body to be loaded.
if (isLoaded) {
assistants[0]?.open(state.ask ?? undefined);
initialRef.current = true;
}
}, [state?.ask, assistants.length, assistants[0]?.open, isLoaded]);
const onClose = React.useCallback(
async (to?: string) => {
setSearchState((prev) =>
prev
? {
...prev,
open: false,
query: prev.query === '' ? null : prev.query,
}
: null
);
if (to) {
router.push(to);
}
},
[setSearchState, router]
);
const {
assistants,
askQuery,
close,
cursor,
error,
fetching,
onInputKeyDown,
open,
query,
results,
resultsId,
resultsRef,
searchValue,
setQuery,
showAsk,
state,
withAI,
withSearchAI,
scopeControl,
} = useSearchController(searchProps);
const uiAssistants = assistants.filter((assistant) => assistant.ui === true);
useHotkeys(
'mod+k',
(e) => {
e.preventDefault();
onOpen();
open();
},
{
enableOnFormTags: true,
@@ -128,35 +68,17 @@ export function SearchContainer({
'mod+i',
(e) => {
e.preventDefault();
if (assistants) {
assistants[0]?.open();
}
assistants[0]?.open();
},
{
enableOnFormTags: true,
}
);
const onOpen = React.useCallback(() => {
if (state?.open) {
return;
}
setSearchState((prev) => ({
ask: withAI ? (prev?.ask ?? null) : null,
scope: prev?.scope ?? 'default',
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
open: true,
}));
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
close();
}
};
document.addEventListener('keydown', handleKeyDown);
@@ -164,110 +86,33 @@ export function SearchContainer({
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const onChange = (value: string) => {
setSearchState((prev) => ({
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
query: value,
scope: prev?.scope ?? 'default',
open: true,
}));
};
// We trim the query to avoid invalidating the search when the user is typing between words.
const normalizedQuery = state?.query?.trim() ?? '';
const normalizedAsk = state?.ask?.trim() ?? '';
const showAsk = withSearchAI && normalizedAsk;
}, [close]);
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
const searchResultsId = `search-results-${React.useId()}`;
// If searching all variants of the current section (the "extended" scope),
// filter by language if the language is set for both the current and the target site space.
const siteSpaceIds = React.useMemo(
() =>
siteSpaces.reduce((acc: string[], ss) => {
if (
!siteSpace.space.language ||
!ss.space.language ||
ss.space.language === siteSpace.space.language
) {
acc.push(ss.id);
}
return acc;
}, []),
[siteSpaces, siteSpace.space.language]
);
const { results, fetching, error } = useSearchResults({
disabled: !(state?.query || withAI),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
scope: state?.scope ?? 'default',
withAI,
suggestions: config.suggestions,
searchURL,
});
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query: normalizedQuery,
results,
});
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveCursorBy(1);
} else if (event.key === 'Enter') {
event.preventDefault();
resultsRef.current?.select();
}
};
const searchResultsActiveDescendant = cursor !== null ? `${resultsId}-${cursor}` : undefined;
return (
<SearchAskProvider value={searchAsk}>
<>
<Popover
content={
// Only show content if there's a query or Ask is enabled
state?.query || withAI ? (
<React.Suspense fallback={null}>
<div className="scroll-py-2 overflow-y-scroll p-2">
{state !== null && !showAsk ? (
<SearchResults
ref={resultsRef}
query={normalizedQuery}
id={searchResultsId}
fetching={fetching}
results={results}
cursor={cursor}
error={error}
/>
) : null}
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
</div>
{(withVariants || withSections) && !showAsk ? (
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
<SearchScopeControl
section={section}
spaceTitle={getLocalizedTitle(
siteSpace,
siteSpace.space.language
)}
withVariants={withVariants}
withSiteVariants={withSiteVariants}
withSections={withSections}
/>
</div>
) : null}
</React.Suspense>
<SearchFrame
askQuery={askQuery}
cursor={cursor}
error={error}
fetching={fetching}
query={query}
results={results}
resultsId={resultsId}
resultsRef={resultsRef}
showAsk={showAsk}
scopeControl={
searchProps.withVariants || searchProps.withSections ? (
<SearchScopeControl {...scopeControl} />
) : null
}
/>
) : null
}
rootProps={{
@@ -278,14 +123,14 @@ export function SearchContainer({
onOpenAutoFocus: (event) => event.preventDefault(),
align: 'start',
className:
'@container flex flex-col bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
'@container flex flex-col overflow-hidden bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
onInteractOutside: (event) => {
// Don't close if clicking on the search input itself
if (searchInputRef.current?.contains(event.target as Node)) {
event.preventDefault();
return;
}
onClose();
close();
},
sideOffset: 8,
collisionPadding: {
@@ -302,54 +147,32 @@ export function SearchContainer({
>
<SearchInput
ref={searchInputRef}
aria-activedescendant={searchResultsActiveDescendant}
aria-controls={resultsId}
onChange={setQuery}
onKeyDown={onInputKeyDown}
value={searchValue}
onFocus={onOpen}
onChange={onChange}
onKeyDown={onKeyDown}
withAI={withSearchAI}
isOpen={state?.open ?? false}
className={className}
aria-controls={searchResultsId}
aria-activedescendant={
cursor !== null ? `${searchResultsId}-${cursor}` : undefined
}
onFocus={open}
>
<LiveResultsAnnouncer
<SearchLiveResultsAnnouncer
count={results.length}
showing={Boolean(searchValue) && !fetching}
/>
</SearchInput>
</Popover>
{assistants
.filter((assistant) => assistant.ui === true)
.map((assistant, index) => (
<AIChatButton
key={assistant.id}
assistant={assistant}
withShortcut={index === 0}
showLabel={
assistants.filter((assistant) => assistant.ui === true).length === 1 &&
style === CustomizationSearchStyle.Prominent
}
/>
))}
</SearchAskProvider>
);
}
/*
* Screen reader announcement for search results.
* Without it there is no feedback for screen reader users when a search returns no results.
*/
function LiveResultsAnnouncer({ count, showing }: { count: number; showing: boolean }) {
const language = useLanguage();
return (
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
{showing
? count > 0
? t(language, 'search_results_count', count)
: t(language, 'search_no_results')
: ''}
</div>
{uiAssistants.map((assistant, index) => (
<AIChatButton
key={assistant.id}
assistant={assistant}
withShortcut={index === 0}
showLabel={
uiAssistants.length === 1 && style === CustomizationSearchStyle.Prominent
}
/>
))}
</>
);
}
@@ -0,0 +1,90 @@
'use client';
import { type ClassValue, tcls } from '@/lib/tailwind';
import React from 'react';
import {
EmbeddableFrame,
EmbeddableFrameHeader,
EmbeddableFrameMain,
EmbeddableFrameSidebar,
} from '../Embeddable/EmbeddableFrame';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SearchAskAnswer } from './SearchAskAnswer';
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import type { ResultType } from './useSearchResults';
export function SearchFrame(props: {
asEmbeddable?: boolean;
askQuery: string;
cursor: number | null;
error: boolean;
fetching: boolean;
input?: React.ReactNode;
query: string;
results: ResultType[];
resultsId: string;
resultsRef: React.Ref<SearchResultsRef>;
scopeControl?: React.ReactNode;
showAsk: boolean;
sidebar?: React.ReactNode;
className?: ClassValue;
dataTestId?: string;
}) {
const {
askQuery,
asEmbeddable,
className,
cursor,
dataTestId,
error,
fetching,
input,
query,
results,
resultsId,
resultsRef,
scopeControl,
showAsk,
sidebar,
} = props;
const searchAsk = useSearchAskState();
return (
<SearchAskProvider value={searchAsk}>
<EmbeddableFrame
className={tcls('bg-tint-base from-transparent to-transparent', className)}
>
{sidebar ? <EmbeddableFrameSidebar>{sidebar}</EmbeddableFrameSidebar> : null}
<EmbeddableFrameMain data-testid={dataTestId}>
{input ? (
<EmbeddableFrameHeader className="p-3 pb-0">{input}</EmbeddableFrameHeader>
) : null}
<React.Suspense fallback={null}>
<ScrollContainer orientation="vertical" contentClassName="p-3">
{showAsk ? (
<SearchAskAnswer query={askQuery} asEmbeddable={asEmbeddable} />
) : (
<SearchResults
ref={resultsRef}
query={query}
id={resultsId}
fetching={fetching}
results={results}
cursor={cursor}
error={error}
/>
)}
</ScrollContainer>
{scopeControl && !showAsk ? (
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
{scopeControl}
</div>
) : null}
</React.Suspense>
</EmbeddableFrameMain>
</EmbeddableFrame>
</SearchAskProvider>
);
}
@@ -6,14 +6,17 @@ import { Icon } from '@gitbook/icons';
import { Input } from '../primitives';
interface SearchInputProps {
'aria-activedescendant'?: string;
'aria-controls'?: string;
onChange: (value: string) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onFocus: () => void;
onFocus?: () => void;
value: string;
withAI: boolean;
isOpen: boolean;
className?: string;
children?: React.ReactNode;
mode?: 'header' | 'frame';
}
/**
@@ -30,9 +33,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
isOpen,
className,
children,
mode = 'header',
...rest
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const isFrame = mode === 'frame';
const language = useLanguage();
@@ -50,7 +55,11 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
}, [isOpen, value]);
return (
<div className="relative flex @max-2xl:size-9.5 grow">
<div
className={
isFrame ? 'relative flex w-full grow' : 'relative flex @max-2xl:size-9.5 grow'
}
>
<Input
data-testid="search-input"
name="search-input"
@@ -58,14 +67,22 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
containerRef={containerRef as React.RefObject<HTMLDivElement | null>}
sizing="medium"
label={tString(language, withAI ? 'search_or_ask' : 'search')}
className="@max-2xl:absolute inset-y-0 right-0 z-30 @max-2xl:max-w-9.5 grow site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-link/1 @max-2xl:px-2.5 site-header:theme-bold:text-header-link site-header:theme-bold:shadow-none! site-header:theme-bold:backdrop-blur-xl @max-2xl:focus-within:w-56 @max-2xl:focus-within:max-w-[calc(100vw-5rem)] site-header:theme-bold:focus-within:border-header-link/6 site-header:theme-bold:focus-within:ring-header-link/5 site-header:theme-bold:hover:border-header-link/5 site-header:theme-bold:hover:not-focus-within:bg-header-link/2 @max-2xl:has-[input[aria-expanded=true]]:w-56 @max-2xl:has-[input[aria-expanded=true]]:max-w-[calc(100vw-5rem)] @max-2xl:[&_input]:opacity-0 site-header:theme-bold:[&_input]:placeholder:text-header-link/8 @max-2xl:focus-within:[&_input]:opacity-11 @max-2xl:has-[input[aria-expanded=true]]:[&_input]:opacity-11"
className={
isFrame
? 'grow bg-tint-base [&_input]:text-sm'
: '@max-2xl:absolute inset-y-0 right-0 z-30 @max-2xl:max-w-9.5 grow site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-link/1 @max-2xl:px-2.5 site-header:theme-bold:text-header-link site-header:theme-bold:shadow-none! site-header:theme-bold:backdrop-blur-xl @max-2xl:focus-within:w-56 @max-2xl:focus-within:max-w-[calc(100vw-5rem)] site-header:theme-bold:focus-within:border-header-link/6 site-header:theme-bold:focus-within:ring-header-link/5 site-header:theme-bold:hover:border-header-link/5 site-header:theme-bold:hover:not-focus-within:bg-header-link/2 @max-2xl:has-[input[aria-expanded=true]]:w-56 @max-2xl:has-[input[aria-expanded=true]]:max-w-[calc(100vw-5rem)] @max-2xl:[&_input]:opacity-0 site-header:theme-bold:[&_input]:placeholder:text-header-link/8 @max-2xl:focus-within:[&_input]:opacity-11 @max-2xl:has-[input[aria-expanded=true]]:[&_input]:opacity-11'
}
placeholder={`${tString(language, withAI ? 'search_or_ask' : 'search')}…`}
onFocus={onFocus}
onKeyDown={onKeyDown}
leading={
<Icon
icon="search"
className="size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint"
className={
isFrame
? 'size-text-lg shrink-0 text-tint-subtle'
: 'size-text-lg shrink-0 site-header:theme-bold:text-header-link/8 text-tint'
}
/>
}
onValueChange={onChange}
@@ -75,18 +92,27 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={value && isOpen ? 'true' : 'false'}
clearButton={{
className:
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
}}
keyboardShortcut={{
className:
'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
keys: isOpen ? ['esc'] : ['mod', 'k'],
}}
clearButton={
isFrame
? true
: {
className:
'site-header:theme-bold:text-header-link site-header:theme-bold:hover:bg-header-link/3',
}
}
keyboardShortcut={
isFrame
? undefined
: {
className:
'site-header:theme-bold:border-header-link/4 site-header:theme-bold:bg-header-background site-header:theme-bold:text-header-link',
keys: isOpen ? ['esc'] : ['mod', 'k'],
}
}
{...rest}
type="text"
/>
{children}
</div>
);
}
@@ -0,0 +1,18 @@
'use client';
import { t, useLanguage } from '@/intl/client';
export function SearchLiveResultsAnnouncer(props: { count: number; showing: boolean }) {
const { count, showing } = props;
const language = useLanguage();
return (
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
{showing
? count > 0
? t(language, 'search_results_count', count)
: t(language, 'search_no_results')
: ''}
</div>
);
}
@@ -1,3 +1,8 @@
export * from './SearchInput';
export * from './SearchFrame';
export * from './SearchLiveResultsAnnouncer';
export * from './SearchContainer';
export * from './SearchScopeControl';
export * from './search-props';
export * from './useSearch';
export * from './useSearchController';
@@ -0,0 +1,33 @@
import type { GitBookSiteContext } from '@/lib/context';
import type { SiteSection, SiteSpace } from '@gitbook/api';
import { encodeClientSiteSections } from '../SiteSections';
export interface SearchBaseProps {
asEmbeddable?: boolean;
siteSpace: SiteSpace;
siteSpaces: ReadonlyArray<SiteSpace>;
withSections: boolean;
section?: Pick<SiteSection, 'title' | 'icon'>;
withVariants: boolean;
withSiteVariants: boolean;
searchURL: string;
}
export function getSearchBaseProps(context: GitBookSiteContext): SearchBaseProps {
const { siteSpace, visibleSections, visibleSiteSpaces } = context;
return {
searchURL: context.linker.toPathInSpace('~gitbook/search'),
section: visibleSections
? encodeClientSiteSections(context, visibleSections).current
: undefined,
siteSpace,
siteSpaces: visibleSiteSpaces,
withSections: Boolean(visibleSections && visibleSections.list.length > 1),
withSiteVariants:
visibleSections?.list.some(
(section) => section.object === 'site-section' && section.siteSpaces.length > 1
) ?? false,
withVariants: visibleSiteSpaces.length > 1,
};
}
@@ -37,6 +37,7 @@ export type SearchSiteContentScope =
| { mode: 'specific'; siteSpaceIds: string[] };
export interface SearchSiteContentRequest {
asEmbeddable?: boolean;
query: string;
scope: SearchSiteContentScope;
}
@@ -2,7 +2,11 @@
import type { GitBookSiteContext } from '@/lib/context';
import { resolvePageId } from '@/lib/pages';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import {
fetchServerActionSiteContext,
getServerActionBaseContext,
runStreamableServerAction,
} from '@/lib/server-actions';
import { findSiteSpaceBy } from '@/lib/sites';
import { filterOutNullable } from '@/lib/typescript';
import type {
@@ -11,10 +15,10 @@ import type {
SearchAIAnswer,
SearchAIRecommendedQuestionStream,
} from '@gitbook/api';
import { createStreamableValue } from 'ai/rsc';
import type * as React from 'react';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { joinPathWithBaseURL } from '@/lib/paths';
import { traceErrorOnly } from '@/lib/tracing';
@@ -31,96 +35,101 @@ export interface AskAnswerResult {
body?: React.ReactNode;
followupQuestions: string[];
sources: AskAnswerSource[];
/** Set when an error occurred mid-stream, after partial content was already delivered. */
error?: boolean;
}
/**
* Server action to ask a question in a space.
*/
export async function streamAskQuestion({
asEmbeddable,
question,
}: {
asEmbeddable?: boolean;
question: string;
}) {
return traceErrorOnly('Search.streamAskQuestion', async () => {
const responseStream = createStreamableValue<AskAnswerResult | undefined>();
(async () => {
const context = await fetchServerActionSiteContext(await getServerActionBaseContext());
const apiClient = await context.dataFetcher.api();
const stream = apiClient.orgs.streamAskInSite(
context.organizationId,
context.site.id,
{
question,
context: {
siteSpaceId: context.siteSpace.id,
},
scope: {
mode: 'default',
currentSiteSpace: context.siteSpace.id,
},
},
{ format: 'document' }
);
const spacePromises = new Map<string, Promise<Revision>>();
for await (const chunk of stream) {
const answer = chunk.answer;
// Register the space of each page source into the promise queue.
const spaces = answer.sources
.map((source) => {
if (source.type !== 'page') {
return null;
}
if (!spacePromises.has(source.space)) {
spacePromises.set(
source.space,
throwIfDataError(
context.dataFetcher.getRevision({
spaceId: source.space,
revisionId: source.revision,
})
)
);
}
return source.space;
})
.filter(filterOutNullable);
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const revision = await spacePromises.get(space);
return { space, pages: revision?.pages };
})
).then((results) => {
return results.reduce((map, result) => {
if (result.pages) {
map.set(result.space, result.pages);
}
return map;
}, new Map<string, RevisionPage[]>());
});
responseStream.update(
await transformAnswer(context, { answer: chunk.answer, spacePages: pages })
return runStreamableServerAction<AskAnswerResult | undefined>({
onError: (_, lastValue) => ({
...(lastValue ?? { followupQuestions: [], sources: [] }),
error: true,
}),
run: async (push) => {
const context = await fetchServerActionSiteContext(
await getServerActionBaseContext({ isEmbeddable: asEmbeddable })
);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
return {
stream: responseStream.value,
};
const apiClient = await context.dataFetcher.api();
const stream = apiClient.orgs.streamAskInSite(
context.organizationId,
context.site.id,
{
question,
context: {
siteSpaceId: context.siteSpace.id,
},
scope: {
mode: 'default',
currentSiteSpace: context.siteSpace.id,
},
},
{ format: 'document' }
);
const spacePromises = new Map<string, Promise<Revision>>();
for await (const chunk of stream) {
const answer = chunk.answer;
// Register the space of each page source into the promise queue.
const spaces = answer.sources
.map((source) => {
if (source.type !== 'page') {
return null;
}
if (!spacePromises.has(source.space)) {
spacePromises.set(
source.space,
throwIfDataError(
context.dataFetcher.getRevision({
spaceId: source.space,
revisionId: source.revision,
})
)
);
}
return source.space;
})
.filter(filterOutNullable);
// Get the pages for all spaces referenced by this answer.
const pages = await Promise.all(
spaces.map(async (space) => {
const revision = await spacePromises.get(space);
return { space, pages: revision?.pages };
})
).then((results) => {
return results.reduce((map, result) => {
if (result.pages) {
map.set(result.space, result.pages);
}
return map;
}, new Map<string, RevisionPage[]>());
});
push(
await transformAnswer(context, {
answer: chunk.answer,
asEmbeddable: Boolean(asEmbeddable),
spacePages: pages,
})
);
}
},
});
});
}
@@ -133,32 +142,25 @@ export async function streamRecommendedQuestions(args: { siteSpaceId?: string })
const siteURLData = await getSiteURLDataFromMiddleware();
const context = await getServerActionBaseContext();
const responseStream = createStreamableValue<
SearchAIRecommendedQuestionStream | undefined
>();
return runStreamableServerAction<SearchAIRecommendedQuestionStream | undefined>({
// On mid-stream error, pass the last value through to stop cleanly without a throw.
// On pre-stream error, fail() is called so the existing silent catch in the client handles it.
onError: (_, lastValue) => lastValue,
run: async (push) => {
const apiClient = await context.dataFetcher.api();
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
siteURLData.organization,
siteURLData.site,
{
siteSpaceId: args.siteSpaceId,
}
);
(async () => {
const apiClient = await context.dataFetcher.api();
const apiStream = apiClient.orgs.streamRecommendedQuestionsInSite(
siteURLData.organization,
siteURLData.site,
{
siteSpaceId: args.siteSpaceId,
for await (const chunk of apiStream) {
push(chunk);
}
);
for await (const chunk of apiStream) {
responseStream.update(chunk);
}
})()
.then(() => {
responseStream.done();
})
.catch((error) => {
responseStream.error(error);
});
return { stream: responseStream.value };
},
});
});
}
@@ -166,9 +168,11 @@ async function transformAnswer(
context: GitBookSiteContext,
{
answer,
asEmbeddable,
spacePages,
}: {
answer: SearchAIAnswer;
asEmbeddable: boolean;
spacePages: Map<string, RevisionPage[]>;
}
): Promise<AskAnswerResult> {
@@ -197,12 +201,24 @@ async function transformAnswer(
);
const spaceURL = found?.siteSpace.urls.published;
const href = spaceURL
? joinPathWithBaseURL(spaceURL, page.page.path)
: context.linker.toPathForPage({
pages,
page: page.page,
});
let href = context.linker.toPathForPage({
pages,
page: page.page,
});
if (spaceURL) {
if (asEmbeddable) {
href = toEmbeddableLinkForPublishedContent(
context.linker,
spaceURL,
page.page.path
);
} else {
href = context.linker.toLinkForContent(
joinPathWithBaseURL(spaceURL, page.page.path)
);
}
}
return {
id: source.page,
@@ -0,0 +1,228 @@
'use client';
import { getLocalizedTitle } from '@/lib/sites';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useAI } from '../AI';
import { useTrackEvent } from '../Insights';
import { useBodyLoaded } from '../primitives';
import type { SearchResultsRef } from './SearchResults';
import type { SearchBaseProps } from './search-props';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
function useInitialAskBootstrap(props: {
asEmbeddable?: boolean;
assistants: ReturnType<typeof useAI>['assistants'];
initialAsk: string | null;
isLoaded: boolean;
}) {
const { asEmbeddable, assistants, initialAsk, isLoaded } = props;
const handledInitialAskRef = React.useRef<string | null | undefined>(undefined);
React.useEffect(() => {
if (asEmbeddable) return;
if (assistants.length === 0) return;
if (initialAsk === null) return;
if (handledInitialAskRef.current === initialAsk) return;
// For simplicity we're only triggering the first assistant.
if (isLoaded) {
assistants[0]?.open(initialAsk || undefined);
handledInitialAskRef.current = initialAsk;
}
}, [asEmbeddable, assistants, initialAsk, isLoaded]);
}
function useFilteredSiteSpaceIds(props: {
siteSpaces: SearchBaseProps['siteSpaces'];
language: string | null | undefined;
}) {
const { siteSpaces, language } = props;
return React.useMemo(
() =>
siteSpaces.reduce((acc: string[], siteSpace) => {
if (
!language ||
!siteSpace.space.language ||
siteSpace.space.language === language
) {
acc.push(siteSpace.id);
}
return acc;
}, []),
[siteSpaces, language]
);
}
function useSearchKeyboardNavigation(props: {
query: string;
results: ReturnType<typeof useSearchResults>['results'];
resultsRef: React.RefObject<SearchResultsRef | null>;
}) {
const { query, results, resultsRef } = props;
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
query,
results,
});
const onInputKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
moveCursorBy(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
moveCursorBy(1);
} else if (event.key === 'Enter') {
event.preventDefault();
resultsRef.current?.select();
}
},
[moveCursorBy, resultsRef]
);
return {
cursor,
onInputKeyDown,
};
}
export function useSearchController(props: SearchBaseProps) {
const {
asEmbeddable,
siteSpace,
section,
withVariants,
withSiteVariants,
withSections,
siteSpaces,
searchURL,
} = props;
const { assistants, config } = useAI();
const state = useSearchState();
const setSearchState = useSetSearchState();
const router = useRouter();
const trackEvent = useTrackEvent();
const resultsRef = React.useRef<SearchResultsRef>(null);
const isLoaded = useBodyLoaded();
const withAI = assistants.length > 0;
const withSearchAI = assistants.filter((assistant) => assistant.mode === 'search').length > 0;
// Handle initial ask state on page load, once assistants are ready.
// `ask=` should still bootstrap the assistant on the docs site, so we must
// distinguish between `null` (no ask param) and an empty string.
const initialAsk = state?.ask ?? null;
useInitialAskBootstrap({ asEmbeddable, assistants, initialAsk, isLoaded });
const onClose = React.useCallback(
async (to?: string) => {
setSearchState((prev) =>
prev
? {
...prev,
open: false,
query: prev.query === '' ? null : prev.query,
}
: null
);
if (to) {
router.push(to);
}
},
[setSearchState, router]
);
const onOpen = React.useCallback(() => {
if (state?.open) {
return;
}
setSearchState((prev) => ({
ask: withAI ? (prev?.ask ?? null) : null,
scope: prev?.scope ?? 'default',
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
open: true,
}));
trackEvent({
type: 'search_open',
});
}, [state?.open, setSearchState, trackEvent, withAI, withSearchAI]);
const setQuery = React.useCallback(
(value: string) => {
setSearchState((prev) => ({
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null,
query: value,
scope: prev?.scope ?? 'default',
open: true,
}));
},
[setSearchState, withAI, withSearchAI]
);
const normalizedQuery = state?.query?.trim() ?? '';
const normalizedAsk = state?.ask?.trim() ?? '';
const showAsk = withSearchAI && normalizedAsk.length > 0;
// If searching all variants of the current section (the "extended" scope),
// filter by language if the language is set for both the current and the target site space.
const siteSpaceIds = useFilteredSiteSpaceIds({
siteSpaces,
language: siteSpace.space.language,
});
const { results, fetching, error } = useSearchResults({
asEmbeddable,
disabled: !(state?.query || withAI),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
scope: state?.scope ?? 'default',
suggestions: config.suggestions,
searchURL,
});
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
const searchResultsId = `search-results-${React.useId()}`;
const { cursor, onInputKeyDown } = useSearchKeyboardNavigation({
query: normalizedQuery,
results,
resultsRef,
});
return {
assistants,
askQuery: normalizedAsk,
cursor,
error,
fetching,
open: onOpen,
close: onClose,
query: normalizedQuery,
results,
resultsId: searchResultsId,
resultsRef,
searchValue,
setQuery,
state,
onInputKeyDown,
showAsk,
withAI,
withSearchAI,
scopeControl: {
section,
spaceTitle: getLocalizedTitle(siteSpace, siteSpace.space.language),
withVariants,
withSiteVariants,
withSections,
},
};
}
@@ -8,6 +8,7 @@ import { streamRecommendedQuestions } from './server-actions';
import { type Assistant, useAI } from '@/components/AI';
import assertNever from 'assert-never';
import { useEventCallback } from 'usehooks-ts';
import { useTrackEvent } from '../Insights';
import { isQuestion } from './isQuestion';
import type { SearchScope } from './useSearch';
@@ -26,17 +27,26 @@ export type ResultType =
const cachedRecommendedQuestions: Map<string, ResultType[]> = new Map();
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
scope: SearchScope;
withAI: boolean;
suggestions?: string[];
/** URL for the search API route (e.g. from linker.toPathInSpace('~gitbook/search')). */
searchURL: string;
}) {
const { disabled, query, siteSpaceId, siteSpaceIds, scope, suggestions, searchURL } = props;
const {
asEmbeddable,
disabled,
query,
siteSpaceId,
siteSpaceIds,
scope,
suggestions,
searchURL,
} = props;
const trackEvent = useTrackEvent();
@@ -47,6 +57,7 @@ export function useSearchResults(props: {
}>({ results: [], fetching: false, error: false });
const { assistants } = useAI();
const getAssistants = useEventCallback(() => assistants);
const withAI = assistants.length > 0;
React.useEffect(() => {
@@ -134,7 +145,7 @@ export function useSearchResults(props: {
};
}
setResultsState({
results: withAI ? withAskTriggers([], query, assistants) : [],
results: withAI ? withAskTriggers([], query, getAssistants()) : [],
fetching: true,
error: false,
});
@@ -146,7 +157,13 @@ export function useSearchResults(props: {
const fetchSearch = (
scope: Parameters<typeof fetchSearchResults>[1]
): Promise<OrderedComputedResult[]> =>
fetchSearchResults(searchURL, scope, query, abortController.signal);
fetchSearchResults(
searchURL,
scope,
query,
abortController.signal,
asEmbeddable
);
switch (scope) {
case 'all':
@@ -179,7 +196,7 @@ export function useSearchResults(props: {
}
const aiEnrichedResults = withAI
? withAskTriggers(results, query, assistants)
? withAskTriggers(results, query, getAssistants())
: results;
setResultsState({ results: aiEnrichedResults, fetching: false, error: false });
@@ -195,7 +212,7 @@ export function useSearchResults(props: {
}
setResultsState({ results: [], fetching: false, error: true });
}
}, 350);
}, 200);
return () => {
cancelled = true;
@@ -212,6 +229,8 @@ export function useSearchResults(props: {
disabled,
suggestions,
searchURL,
asEmbeddable,
getAssistants,
]);
return resultsState;
@@ -227,12 +246,14 @@ async function fetchSearchResults(
| { mode: 'current'; siteSpaceId: string }
| { mode: 'specific'; siteSpaceIds: string[] },
query: string,
signal?: AbortSignal
signal?: AbortSignal,
asEmbeddable?: boolean
): Promise<OrderedComputedResult[]> {
const response = await fetch(searchURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asEmbeddable,
query,
scope,
}),
@@ -193,6 +193,6 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P
? 'black'
: 'default',
},
robots: (await isSiteIndexable(context)) ? 'index, follow' : 'noindex, nofollow',
robots: isSiteIndexable(context) ? 'index, follow' : 'noindex, nofollow',
};
}

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