mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-28 13:08:57 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9c733417f | |||
| 49694d493e | |||
| 9d8a0b8832 | |||
| be080248b1 | |||
| d3d51507e5 | |||
| a17ab65a6d | |||
| e3aca9e4f6 | |||
| 045f603849 | |||
| b2854500a1 | |||
| ead88591c2 | |||
| aa1fc246e2 | |||
| e792cffeb3 | |||
| 119e3bc369 | |||
| 823e7d5c4b | |||
| f5f659e7fc | |||
| 723e579958 | |||
| f27901c453 | |||
| 789c2fab60 | |||
| 3fa727c80b | |||
| 88c38fa505 | |||
| bda9fe2d76 | |||
| 2bdade3b57 | |||
| 8d2a95b168 | |||
| 68c842bd4c | |||
| ecb4292551 | |||
| 7fd381c0b1 | |||
| 709964fe3e | |||
| afc7a38bef | |||
| bbcfd72a6d | |||
| 8242f18b8a | |||
| 4f7fba08df | |||
| f17e292ba4 | |||
| 6e1074ec4d | |||
| c5e5857623 | |||
| 11df1fd395 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve Assistant form controls
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add "Content-Signal" to "robots.txt"
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support variant selector in hidden section
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Improve AI agent detection and readability by outputting 200 for page not found.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"gitbook/tsconfig.json",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
|
||||
+7
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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',
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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('')));
|
||||
}
|
||||
+3
@@ -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>
|
||||
|
||||
+16
@@ -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} />;
|
||||
}
|
||||
+110
-98
@@ -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];
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
});
|
||||
|
||||
|
||||
+16
@@ -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} />;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user