mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 11:03:39 +00:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ae09b1ec5 | |||
| f71ab8426c | |||
| a3d1558f99 | |||
| ca53869e2d | |||
| 33598e665b | |||
| 9045ba5248 | |||
| 6cd9f2aaaa | |||
| 6d4a1653a2 | |||
| 9ce2759e1b | |||
| 5663064b46 | |||
| 6246b106af | |||
| 9ad85cb30c | |||
| 25bcff6eff | |||
| f232b8fff5 | |||
| e7f9bc2565 | |||
| 7061fc2223 | |||
| 21053f56fb | |||
| 2746ffc000 | |||
| 568ee3a660 | |||
| 420bf4a6f6 | |||
| 72cffdd14b | |||
| 0c9e3952b7 | |||
| 07be0e224c | |||
| c765394914 | |||
| ab96ee806b | |||
| 7cc6090e03 | |||
| 4b78672135 | |||
| 10289e4881 | |||
| 8b6a6df1d6 | |||
| 83df84ac28 | |||
| 5e5075a137 | |||
| 90c1f68e19 | |||
| 0e10124626 | |||
| 01e1c2ae6a | |||
| 65558cfbb1 | |||
| 10d894ce19 | |||
| d1a2958212 | |||
| 894b6adc4c | |||
| b3b819f8e5 | |||
| 2b98af0cc0 | |||
| 54a56ed772 | |||
| 071627d4f0 | |||
| 7595706b92 | |||
| 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 | |||
| 569d4046be | |||
| 50653abd08 | |||
| 84082225af | |||
| 7d882cef5f | |||
| 126d4b78b6 | |||
| e8224bad6c | |||
| 6063437513 | |||
| ab17ff47b0 | |||
| d97b404c7a | |||
| 3c141f0231 | |||
| e3d391e01b | |||
| 62cfec874a | |||
| ccd21e4943 | |||
| 696d9d0d01 | |||
| b77c4fc5b9 | |||
| 7e1ea48080 | |||
| b5d6f2e908 | |||
| 832b44f0ac | |||
| 4909044831 | |||
| 4ed84c209a | |||
| ca01888fe2 | |||
| 6855c05781 | |||
| ab867eb69c | |||
| deabce98ae | |||
| f884477a65 | |||
| c56b569a05 | |||
| 1ba412ff6c | |||
| 7c2823cd26 | |||
| 16bfafe32b | |||
| da648cabd8 | |||
| a36e437580 | |||
| d5dd5e7ba1 | |||
| c4a2604b4d | |||
| 7f4e644733 | |||
| 5d3f78b112 | |||
| 60bcf43304 | |||
| b40465e7b7 | |||
| 3151864f81 | |||
| 80803c92f2 | |||
| 31e77d4e17 | |||
| 8e5a2aa6e6 | |||
| fe6f6420e3 | |||
| aff6285eb9 | |||
| 5f132bc979 | |||
| 4297de3571 | |||
| 6455f2e3ff | |||
| dee52fa80a | |||
| 4ac29817a2 | |||
| f09ca60930 | |||
| a7c0f7251b | |||
| efc6117665 | |||
| 6c613d0df3 | |||
| 45d32b5084 | |||
| 0f62a629b4 | |||
| 7ab63516bd | |||
| 9dd2db6678 | |||
| 4787b7e910 | |||
| be02e33ada |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix Variant dropdown not showing in GitBook embed
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add "Content-Signal" to "robots.txt"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support variant selector in hidden section
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/icons": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Update icon usage to render svg symbols rather than file.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix embeddable search links
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix links to other spaces/sections in the llms.txt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Pass visitor session to Ask AI.
|
||||
@@ -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
|
||||
@@ -136,7 +137,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: v2-vercel
|
||||
visual-testing-v2-cloudflare:
|
||||
@@ -161,7 +161,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: v2-cloudflare
|
||||
visual-testing-customers-v2:
|
||||
@@ -186,7 +185,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-vercel
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
@@ -211,7 +209,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-cloudflare
|
||||
browserless-testing-v2-vercel:
|
||||
@@ -235,7 +232,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
# browserless-testing-v2-cloudflare:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Browserless Testing v2 (Cloudflare)
|
||||
|
||||
@@ -26,9 +26,10 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
run: |
|
||||
corepack enable npm
|
||||
corepack install -g npm@latest
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
@@ -45,4 +46,3 @@ jobs:
|
||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -1,3 +1,77 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
- After making code changes, run `bun format`.
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies
|
||||
bun dev # Start dev server (all packages)
|
||||
bun run build # Build all packages
|
||||
bun run format # Format with Biome (run after every change)
|
||||
bun run typecheck # Type-check all packages
|
||||
bun run unit # Run unit tests
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The dev server proxies published GitBook sites locally. After `bun dev`, access any published site at:
|
||||
|
||||
```
|
||||
http://localhost:3000/url/<published-gitbook-url>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `http://localhost:3000/url/gitbook.com/docs`
|
||||
- `http://localhost:3000/url/open-source.gitbook.io/midjourney`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
packages/
|
||||
gitbook/ # Main Next.js app
|
||||
src/
|
||||
app/ # Next.js App Router (sites/)
|
||||
components/ # React components
|
||||
lib/ # Server utilities, data fetching
|
||||
intl/ # Internationalization (translations/)
|
||||
openapi-parser/ # OpenAPI 3.0/3.1/Swagger parser
|
||||
react-openapi/ # OpenAPI rendering components
|
||||
react-contentkit/ # ContentKit component rendering
|
||||
embed/ # Embeddable GitBook components
|
||||
shared/ # Shared utilities
|
||||
icons/ # Icon assets
|
||||
fonts/ # Font assets
|
||||
colors/ # Color tokens
|
||||
expr/ # GitBook expression evaluator
|
||||
cache-do/ # Cloudflare DO cache
|
||||
cache-tags/ # Cache tag utilities
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bun run unit # Unit tests via bun test (not vitest)
|
||||
bun run e2e # Playwright e2e tests (requires built app)
|
||||
```
|
||||
|
||||
Run a specific test file:
|
||||
```bash
|
||||
cd packages/gitbook && bun test src/lib/cache.test.ts
|
||||
```
|
||||
|
||||
## Changesets
|
||||
|
||||
After committing code changes, create a changeset for the affected package:
|
||||
|
||||
```md
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Provide a short description of the change.
|
||||
```
|
||||
|
||||
Save as `.changeset/<name>.md`, then commit it separately with message: `changeset`
|
||||
|
||||
## Formatting
|
||||
|
||||
Uses Biome (not ESLint/Prettier). Always run `bun run format` before committing.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"gitbook/tsconfig.json",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
|
||||
+10
-6
@@ -7,8 +7,8 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.8.16",
|
||||
"vercel": "^50.31.1"
|
||||
"turbo": "^2.9.6",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
"overrides": {
|
||||
@@ -17,7 +17,9 @@
|
||||
"@types/react-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2"
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -41,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.167.0",
|
||||
"@gitbook/api": "0.180.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -53,11 +55,13 @@
|
||||
"react-dom": "^19.0.1",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vercel": "^50.26.1"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||
"@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) => ({
|
||||
|
||||
@@ -36,4 +36,4 @@ screenshots/
|
||||
# cloudflare
|
||||
.open-next
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
worker-configuration.d.ts
|
||||
|
||||
@@ -1,5 +1,94 @@
|
||||
# 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
|
||||
|
||||
- Updated dependencies [ab17ff4]
|
||||
- @gitbook/icons@0.4.6
|
||||
- @gitbook/react-contentkit@0.7.15
|
||||
|
||||
## 0.23.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3c141f0]
|
||||
- @gitbook/icons@0.4.5
|
||||
- @gitbook/react-contentkit@0.7.14
|
||||
|
||||
## 0.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8e5a2aa: Pan and zoom controls for mermaid diagrams
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b77c4fc: Add getPage MCP tool
|
||||
- 31e77d4: Track new events for site analytics.
|
||||
- f884477: Associate questions asked in the embeddable assistant to the proper channel.
|
||||
- deabce9: Sort results by score to mix records and pages.
|
||||
- 4909044: Add Claude Code and Codex connect actions
|
||||
- da648ca: Fix regressions in Assistant UX
|
||||
- 3ce7e52: Fix Variant dropdown not showing in GitBook embed
|
||||
- 7f4e644: Small OpenAPI Improvements
|
||||
- 4787b7e: Use localized titles/descriptions for site entities
|
||||
- 7ab6351: Import site redirect patterns from @gitbook/api
|
||||
- b40465e: Fix normalization of URL causing invalid redirections.
|
||||
- b5d6f2e: Fix translation quality across 11 locales
|
||||
- 3151864: Track events from the proper server URL.
|
||||
- 16bfafe: Add option to sticky header row in tables
|
||||
- 7c2823c: Fix full width header alignment
|
||||
- c4a2604: Fix PDF export crash when page contains an OpenAPI block
|
||||
- c56b569: Support expand-all options for OpenAPI
|
||||
- Updated dependencies [7f4e644]
|
||||
- Updated dependencies [b5d6f2e]
|
||||
- Updated dependencies [9dd2db6]
|
||||
- Updated dependencies [c56b569]
|
||||
- Updated dependencies [ccd21e4]
|
||||
- @gitbook/react-openapi@1.5.11
|
||||
- @gitbook/openapi-parser@3.0.11
|
||||
- @gitbook/icons@0.4.4
|
||||
- @gitbook/react-contentkit@0.7.13
|
||||
|
||||
## 0.22.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
|
||||
|
||||
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
|
||||
|
||||
const CLOSED_ONETRUST_COOKIE_BANNER = [
|
||||
{
|
||||
name: '__gitbook_cookie_granted',
|
||||
value: 'no',
|
||||
},
|
||||
{
|
||||
name: 'OptanonAlertBoxClosed',
|
||||
value: new Date(Date.now() - ONE_HOUR_IN_MS).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
/** A list of test cases to run on the customers' docs sites. */
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
@@ -97,11 +110,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// Disabling for now, one of the image is making the tests timeout
|
||||
// {
|
||||
// name: 'docs.keeper.io',
|
||||
// contentBaseURL: 'https://docs.keeper.io',
|
||||
// tests: [{ name: 'Home', url: '/en', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'adiblar.gitbook.io',
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
@@ -209,11 +223,12 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.meshchain.ai',
|
||||
contentBaseURL: 'https://docs.meshchain.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// This one redirects to binance now
|
||||
// {
|
||||
// name: 'docs.meshchain.ai',
|
||||
// contentBaseURL: 'https://docs.meshchain.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.thousandeyes.com',
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
@@ -233,7 +248,11 @@ const testCases: TestsCase[] = [
|
||||
name: 'run-ai-docs.nvidia.com',
|
||||
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
cookies: CLOSED_ONETRUST_COOKIE_BANNER,
|
||||
},
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationDepth,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationThemeMode,
|
||||
SiteSocialAccountPlatform,
|
||||
} from '@gitbook/api';
|
||||
import type { GitBookStandalone } from '@gitbook/embed';
|
||||
@@ -16,7 +16,7 @@ import jwt from 'jsonwebtoken';
|
||||
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type Test,
|
||||
type TestsCase,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. Search for "Lorem ipsum", then return the first sentence of the first page you find.`;
|
||||
|
||||
const overrideAIInitialState = () => {
|
||||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||
@@ -69,7 +69,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.getByTestId('search-input');
|
||||
@@ -83,8 +82,6 @@ const searchTestCases: Test[] = [
|
||||
});
|
||||
const pageResults = await page.getByTestId('search-page-result').all();
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
await expect(pageSectionResults.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||||
},
|
||||
},
|
||||
@@ -95,7 +92,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.None,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+K');
|
||||
@@ -149,7 +145,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
@@ -186,7 +181,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
@@ -203,7 +197,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
@@ -220,7 +213,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
@@ -238,7 +230,6 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
@@ -327,7 +318,6 @@ const testCases: TestsCase[] = [
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -623,7 +613,6 @@ const testCases: TestsCase[] = [
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
@@ -709,10 +698,30 @@ const testCases: TestsCase[] = [
|
||||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Show error when missing token',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
// Intentionally not setting the token to test error handling when the token is missing
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('limit', '10');
|
||||
|
||||
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page, response) => {
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.status()).toBe(400);
|
||||
await expect(page.getByText('Missing API token')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Preview',
|
||||
name: 'Site Previews',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
@@ -725,7 +734,7 @@ const testCases: TestsCase[] = [
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
@@ -740,7 +749,7 @@ const testCases: TestsCase[] = [
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
@@ -750,10 +759,74 @@ const testCases: TestsCase[] = [
|
||||
const sectionTabLinks = sectionTabs.getByRole('link');
|
||||
for (const link of await sectionTabLinks.all()) {
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toMatch(/^\/url\/preview\/site_p4Xo4\/?/);
|
||||
expect(href?.includes('/preview/site_p4Xo4')).toBeTruthy();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With customization cookie',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
// Trademark exists by default
|
||||
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
|
||||
1
|
||||
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
|
||||
|
||||
// Go to another page with the customization query to disable the trademark
|
||||
const pageBlocks = new URL(page.url());
|
||||
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
|
||||
pageBlocks.search = getCustomizationURL({
|
||||
trademark: {
|
||||
enabled: false,
|
||||
},
|
||||
}).slice(1);
|
||||
await page.goto(pageBlocks.toString());
|
||||
// No trademark because customization is disabled
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Blocks' })
|
||||
).toBeVisible();
|
||||
|
||||
const pageBlocksCode = new URL(page.url());
|
||||
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
|
||||
pageBlocksCode.search = '';
|
||||
await page.goto(pageBlocksCode.toString());
|
||||
// The trademark should not be visible because the cookie is still set,
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Code' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Redirect to app for authentication when missing token',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
// Intentionally not setting the token to test redirection for authentication
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.waitForURL(
|
||||
(url) =>
|
||||
url.host === 'app.gitbook.com' && url.pathname.includes('/preview/auth')
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -793,9 +866,15 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Search & AI',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: searchTestCases,
|
||||
},
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
fullPage: true,
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -1019,7 +1098,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'With cover for dark mode',
|
||||
url: `page-options/page-with-dark-cover${getCustomizationURL({
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Dark,
|
||||
default: CustomizationDefaultThemeMode.Dark,
|
||||
toggeable: false,
|
||||
},
|
||||
})}`,
|
||||
@@ -1306,7 +1385,7 @@ const testCases: TestsCase[] = [
|
||||
).toBeVisible();
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
|
||||
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -1326,7 +1405,7 @@ const testCases: TestsCase[] = [
|
||||
).toBeVisible();
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
|
||||
expect(url.endsWith('/shared/')).toBeTruthy(); // correct page
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -1337,13 +1416,21 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to SSO page',
|
||||
name: 'Basic redirect',
|
||||
url: 'a/redirect/to/sso',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SSO');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Complex wildcard with special characters',
|
||||
url: 'foo/bar/baz/123456789-welcome-to-gitbook-%22%20target=%22_blank',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SEO');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2069,7 +2156,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Docs Embed - Basic',
|
||||
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Standalone UX',
|
||||
@@ -2116,7 +2203,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Docs Embed - Assistant + Docs',
|
||||
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
@@ -2144,7 +2231,7 @@ const testCases: TestsCase[] = [
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||
GitBook('navigateToPage', '/text-page');
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
@@ -2153,7 +2240,7 @@ const testCases: TestsCase[] = [
|
||||
});
|
||||
await expect(iframe.owner()).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('getting-started/quickstart')
|
||||
expect.stringContaining('text-page')
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -2214,7 +2301,7 @@ const testCases: TestsCase[] = [
|
||||
onClick: () => {
|
||||
const GitBook =
|
||||
window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||
GitBook('navigateToPage', '/text-page');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2255,7 +2342,7 @@ const testCases: TestsCase[] = [
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible();
|
||||
await expect(iframe.owner()).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('getting-started/quickstart')
|
||||
expect.stringContaining('text-page')
|
||||
);
|
||||
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Open external link');
|
||||
@@ -2339,6 +2426,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Docs only',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
@@ -2350,6 +2443,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Table of contents',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
@@ -2365,6 +2464,12 @@ const testCases: TestsCase[] = [
|
||||
name: 'Open in new tab',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||
GitBook('configure', {
|
||||
tabs: ['docs'],
|
||||
});
|
||||
});
|
||||
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultFont,
|
||||
CustomizationDefaultMonospaceFont,
|
||||
CustomizationDefaultThemeMode,
|
||||
CustomizationDepth,
|
||||
type CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationTheme,
|
||||
CustomizationThemeMode,
|
||||
type CustomizationThemedColor,
|
||||
type SiteCustomizationSettings,
|
||||
SiteExternalLinksTarget,
|
||||
@@ -75,6 +75,10 @@ export type TestsCase = {
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
contentBaseURL?: string;
|
||||
/**
|
||||
* Whether screenshots in this test case should capture the full scrollable page by default.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
export const allLocales: CustomizationLocale[] = [
|
||||
@@ -84,9 +88,9 @@ export const allLocales: CustomizationLocale[] = [
|
||||
CustomizationLocale.Zh,
|
||||
];
|
||||
|
||||
export const allThemeModes: CustomizationThemeMode[] = [
|
||||
CustomizationThemeMode.Light,
|
||||
CustomizationThemeMode.Dark,
|
||||
export const allThemeModes: CustomizationDefaultThemeMode[] = [
|
||||
CustomizationDefaultThemeMode.Light,
|
||||
CustomizationDefaultThemeMode.Dark,
|
||||
];
|
||||
|
||||
export const allTintColors: Array<{
|
||||
@@ -142,6 +146,11 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type IconURLState = { state: 'pending'; uri: null } | { state: 'loaded'; uri: string };
|
||||
type IconStateWindow = Window & {
|
||||
__ICONS_STATES__?: Record<string, IconURLState>;
|
||||
};
|
||||
|
||||
export async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible({
|
||||
@@ -240,9 +249,9 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
fullPage: testEntry.fullPage ?? testCase.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
@@ -340,7 +349,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
groups: [],
|
||||
},
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Light,
|
||||
default: CustomizationDefaultThemeMode.System,
|
||||
toggeable: true,
|
||||
},
|
||||
pdf: {
|
||||
@@ -392,11 +401,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
export async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urlStates: Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
const iconWindow = window as IconStateWindow;
|
||||
const urlStates: Record<string, IconURLState> = iconWindow.__ICONS_STATES__ || {};
|
||||
iconWindow.__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
@@ -429,6 +436,20 @@ export async function waitForIcons(page: Page) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const svgSymbol = icon.querySelector('[data-testid="symbol-use"]');
|
||||
if (svgSymbol) {
|
||||
if (icon.dataset.gbIconSymbolState === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const href = svgSymbol.getAttribute('href') ?? svgSymbol.getAttribute('xlink:href');
|
||||
if (!href?.startsWith('#')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return document.getElementById(href.slice(1)) instanceof SVGElement;
|
||||
}
|
||||
|
||||
const state = icon.getAttribute('data-argos-state');
|
||||
|
||||
if (state === 'pending') {
|
||||
|
||||
@@ -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,15 +35,18 @@ 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,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
@@ -39,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",
|
||||
@@ -92,7 +92,8 @@
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
@@ -136,7 +137,8 @@
|
||||
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
||||
// This means that we'll have up to 100 durable objects instance running at the same time
|
||||
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true"
|
||||
"OPEN_NEXT_REQUEST_ID_HEADER": "true",
|
||||
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,7 @@ export class GitbookIncrementalCache implements IncrementalCache {
|
||||
cacheType?: CacheType
|
||||
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
console.log(`[GitbookIncrementalCache] Getting cache for key: ${cacheKey}`, key, cacheType);
|
||||
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
bypassTagCacheOnCacheHit: false,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
// export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
// mode: 'long-lived',
|
||||
// // Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// // TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
// bypassTagCacheOnCacheHit: false,
|
||||
// //TODO: remove, reducing cache ttl of regional cache to help debugging
|
||||
// defaultLongLivedTtlSec: 5 * 60 /* 5 minutes */,
|
||||
// // We don't want to update the cache entry on every cache hit
|
||||
// shouldLazilyUpdateOnCacheHit: false,
|
||||
// });
|
||||
|
||||
//TODO: reenable regional cache once we know what's going on
|
||||
export default new GitbookIncrementalCache();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.22.3",
|
||||
"version": "0.25.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -19,17 +19,20 @@
|
||||
"@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",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -37,22 +40,23 @@
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"feed": "^5.1.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leven": "^4.1.0",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"mermaid": "^11.12.3",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "15.4.11",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -60,9 +64,10 @@
|
||||
"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",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -95,8 +100,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:",
|
||||
@@ -108,18 +113,18 @@
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^50.15.1",
|
||||
"wrangler": "^4.43.0",
|
||||
"vercel": "50.37.3",
|
||||
"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",
|
||||
@@ -135,5 +140,9 @@
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Redirect to the upstream auth provider login URL of site, or to the site root when not configured.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
const noLoginFallbackURL = context.linker.toAbsoluteURL(context.linker.toPathInSite(''));
|
||||
|
||||
if (!context.site.urls.login) {
|
||||
return NextResponse.redirect(noLoginFallbackURL);
|
||||
}
|
||||
|
||||
try {
|
||||
const loginURL = new URL(context.site.urls.login);
|
||||
const location = request.nextUrl.searchParams.get('location');
|
||||
if (location) {
|
||||
loginURL.searchParams.set('location', location);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(loginURL);
|
||||
} catch (_error) {
|
||||
return NextResponse.redirect(noLoginFallbackURL);
|
||||
}
|
||||
}
|
||||
+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} />;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { handleMcpRequest } from '../handler';
|
||||
|
||||
async function handler(
|
||||
rawRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp/auth');
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
|
||||
import { getExposableError, throwIfDataError } from '@/lib/data';
|
||||
import { getMarkdownForPageInSpace } from '@/lib/markdownPage';
|
||||
import { resolvePagePath } from '@/lib/pages';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy, findSiteSpaceByUrl } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
export async function handleMcpRequest(
|
||||
rawRequest: NextRequest,
|
||||
params: RouteLayoutParams,
|
||||
endpoint: '~gitbook/mcp' | '~gitbook/mcp/auth'
|
||||
) {
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
if (!context.customization.pageActions.mcp) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
|
||||
requestURL.search = rawRequest.nextUrl.search;
|
||||
const request = new Request(requestURL, rawRequest);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: context.site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'mcp_request',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Server,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((result) => {
|
||||
if (result.type === 'record') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${result.title}`,
|
||||
`Link: ${result.url}`,
|
||||
result.description ? `Content: ${result.description}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === result.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const siteUrl = context.siteSpace.urls.published;
|
||||
server.tool(
|
||||
'getPage',
|
||||
`Fetch the full markdown content of a specific documentation page from ${site.title}. Use this when you have a page URL and want to read its content. Accepts full URLs (e.g. ${siteUrl}/getting-started). Since \`searchDocumentation\` returns partial content, use \`getPage\` to retrieve the complete page when you need more details. The content includes links you can follow to navigate to related pages.`,
|
||||
{
|
||||
url: z
|
||||
.string()
|
||||
.describe('The URL of the page to fetch')
|
||||
.transform((value, ctx) => {
|
||||
if (URL.canParse(value)) {
|
||||
return value;
|
||||
}
|
||||
if (URL.canParse(`https://${value}`)) {
|
||||
return `https://${value}`;
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${value}" is not a valid URL. Expected a full URL like ${siteUrl}/getting-started`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}),
|
||||
},
|
||||
async ({ url }) => {
|
||||
try {
|
||||
const match = findSiteSpaceByUrl(context.structure, url);
|
||||
if (!match) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const revision = await throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: match.siteSpace.space.id,
|
||||
revisionId: match.siteSpace.space.revision,
|
||||
})
|
||||
);
|
||||
|
||||
const resolved = resolvePagePath(revision.pages, match.pagePath ?? '');
|
||||
if (!resolved) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Page not found: "${url}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const markdown = await getMarkdownForPageInSpace(
|
||||
context,
|
||||
match.siteSpace,
|
||||
resolved.page
|
||||
);
|
||||
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'page_view',
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
page: resolved.page.id,
|
||||
space: match.siteSpace.space.id,
|
||||
revision: match.siteSpace.space.revision,
|
||||
},
|
||||
},
|
||||
],
|
||||
request,
|
||||
})
|
||||
);
|
||||
|
||||
return { content: [{ type: 'text', text: markdown }] };
|
||||
} catch (error) {
|
||||
const exposable = getExposableError(error);
|
||||
return {
|
||||
content: [{ type: 'text', text: exposable.message }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
streamableHttpEndpoint: context.linker.toPathInSite(endpoint),
|
||||
maxDuration: 60,
|
||||
verboseLogs: true,
|
||||
disableSse: true,
|
||||
}
|
||||
);
|
||||
|
||||
return mcpHandler(request);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { handleMcpRequest } from './handler';
|
||||
|
||||
async function handler(
|
||||
rawRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
return handleMcpRequest(rawRequest, await params, '~gitbook/mcp');
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+134
-92
@@ -4,61 +4,53 @@ 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';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { findSiteSpaceBy, getLocalizedTitle } from '@/lib/sites';
|
||||
import type {
|
||||
SearchPageResult,
|
||||
SearchSpaceResult,
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
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
|
||||
.map((resultItem) => {
|
||||
.flatMap((resultItem) => {
|
||||
if (resultItem.type === 'record') {
|
||||
const result: OrderedComputedResult = {
|
||||
type: 'record',
|
||||
@@ -66,97 +58,147 @@ export async function POST(request: NextRequest) {
|
||||
title: resultItem.title,
|
||||
description: resultItem.description,
|
||||
href: resultItem.url,
|
||||
score: resultItem.score,
|
||||
};
|
||||
return 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) =>
|
||||
transformSitePageResult(context, {
|
||||
return resultItem.pages.map((pageItem) => ({
|
||||
score: pageItem.score,
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
})
|
||||
.flat(2);
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.flatMap((group) => group.items);
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
|
||||
function transformSitePageResult(
|
||||
context: GitBookBaseContext,
|
||||
args: {
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
space?: Space;
|
||||
siteSpace?: SiteSpace;
|
||||
spaceURL?: string;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup;
|
||||
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),
|
||||
});
|
||||
}
|
||||
): OrderedComputedResult[] {
|
||||
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
|
||||
const { linker } = context;
|
||||
|
||||
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,
|
||||
breadcrumbs: [
|
||||
siteSectionGroup && {
|
||||
icon: siteSectionGroup?.icon as IconName,
|
||||
label: siteSectionGroup.title,
|
||||
},
|
||||
siteSection && {
|
||||
icon: siteSection?.icon as IconName,
|
||||
label: siteSection.title,
|
||||
},
|
||||
(siteSection?.siteSpaces?.filter(
|
||||
(space) =>
|
||||
siteSection?.siteSpaces?.filter(
|
||||
(s) => s.space.language === space.space.language
|
||||
).length > 1
|
||||
).length ?? 0) > 1 && siteSpace
|
||||
? {
|
||||
label: siteSpace.title,
|
||||
}
|
||||
: undefined,
|
||||
...pageItem.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
})),
|
||||
].filter((item) => item !== undefined),
|
||||
score: pageItem.score,
|
||||
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,
|
||||
})) ?? [];
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
let sectionHref = linker.toPathInSpace(pageItem.path);
|
||||
|
||||
return [page, ...pageSections];
|
||||
if (spaceURL) {
|
||||
if (asEmbeddable) {
|
||||
sectionHref = toEmbeddableLinkForPublishedContent(
|
||||
linker,
|
||||
spaceURL,
|
||||
section.path
|
||||
);
|
||||
} else {
|
||||
sectionHref = linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, section.path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'section',
|
||||
id: `${page.id}/${section.id}`,
|
||||
title: section.title,
|
||||
href: sectionHref,
|
||||
body: section.body,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: section.score,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Find the best-scoring section to use as a body preview on the page result.
|
||||
const bestSection = pageSections.reduce<ComputedSectionResult | undefined>(
|
||||
(best, section) => (!best || section.score > best.score ? section : best),
|
||||
undefined
|
||||
);
|
||||
if (bestSection) {
|
||||
page.bestSection = {
|
||||
href: bestSection.href,
|
||||
title: bestSection.title,
|
||||
body: bestSection.body,
|
||||
score: bestSection.score,
|
||||
};
|
||||
}
|
||||
|
||||
return [page];
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ export async function GET(
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
|
||||
+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} />;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { serveAskMarkdown } from '@/routes/markdownAsk';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & { question: string }> }
|
||||
) {
|
||||
const { question: encodedQuestion } = await params;
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const question = decodeURIComponent(encodedQuestion);
|
||||
|
||||
return serveAskMarkdown(context, question);
|
||||
}
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
import { SiteInsightsDisplayContext } from '@gitbook/api';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { trackServerInsightsEvents } from '@/lib/tracking';
|
||||
import { waitUntil } from '@/lib/waitUntil';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
async function handler(
|
||||
nextRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
// Track the search event server-side
|
||||
waitUntil(
|
||||
trackServerInsightsEvents({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
events: [
|
||||
{
|
||||
type: 'search_type_query',
|
||||
query,
|
||||
location: {
|
||||
displayContext: SiteInsightsDisplayContext.Mcp,
|
||||
},
|
||||
},
|
||||
],
|
||||
request: nextRequest,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((result) => {
|
||||
if (result.type === 'record') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${result.title}`,
|
||||
`Link: ${result.url}`,
|
||||
result.description ? `Content: ${result.description}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === result.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
basePath: context.linker.toPathInSite('~gitbook/'),
|
||||
streamableHttpEndpoint: '/mcp',
|
||||
maxDuration: 60,
|
||||
verboseLogs: true,
|
||||
disableSse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(
|
||||
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
|
||||
);
|
||||
requestURL.search = nextRequest.nextUrl.search;
|
||||
|
||||
const request = new Request(requestURL, nextRequest);
|
||||
return mcpHandler(request);
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type RevisionPage,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
SiteVisibility,
|
||||
} from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
import {
|
||||
findSiteSpaceBy,
|
||||
getFallbackSiteSpacePath,
|
||||
getLocalizedTitle,
|
||||
listAllSiteSpaces,
|
||||
} from '@/lib/sites';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface RawIndexPage {
|
||||
id: string;
|
||||
title: string;
|
||||
pathname: string;
|
||||
siteSpaceId: string;
|
||||
lang?: string;
|
||||
icon?: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
}
|
||||
|
||||
type AncestorPage = RevisionPageDocument | RevisionPageGroup;
|
||||
|
||||
interface IndexPageEntry {
|
||||
page: RevisionPageDocument;
|
||||
ancestors: AncestorPage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the page tree and return all indexable document pages together with
|
||||
* their ancestor chain (groups + parent documents), enabling breadcrumb generation.
|
||||
*/
|
||||
function getIndexablePagesWithAncestors(
|
||||
rootPages: RevisionPage[],
|
||||
ancestors: AncestorPage[] = []
|
||||
): IndexPageEntry[] {
|
||||
const results: IndexPageEntry[] = [];
|
||||
|
||||
for (const page of rootPages) {
|
||||
if (page.type === 'link' || page.type === 'computed') continue;
|
||||
if (page.hidden || !isPageIndexable([], page)) continue;
|
||||
|
||||
if (page.type === 'document') {
|
||||
results.push({ page, ancestors });
|
||||
// Recurse into children with this document as an ancestor
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
} else if (page.type === 'group') {
|
||||
// Groups themselves are not documents — push them only as ancestors
|
||||
if (page.pages?.length) {
|
||||
results.push(
|
||||
...getIndexablePagesWithAncestors(page.pages as RevisionPage[], [
|
||||
...ancestors,
|
||||
page,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const revalidate = 86400; // 1 day in seconds
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, structure } = context;
|
||||
|
||||
const visibleSpaces = listAllSiteSpaces(structure).filter((ss) => !ss.hidden);
|
||||
|
||||
const revisions = await Promise.all(
|
||||
visibleSpaces.map((ss) =>
|
||||
throwIfDataError(
|
||||
dataFetcher.getRevision({
|
||||
spaceId: ss.space.id,
|
||||
revisionId: ss.space.revision,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const pages: RawIndexPage[] = [];
|
||||
|
||||
for (let i = 0; i < visibleSpaces.length; i++) {
|
||||
const siteSpace = visibleSpaces[i]!;
|
||||
const revision = revisions[i]!;
|
||||
const forkedLinker = linker.withOtherSiteSpace({
|
||||
spaceBasePath: getFallbackSiteSpacePath(context, siteSpace),
|
||||
});
|
||||
|
||||
const lang = siteSpace.space.language ?? undefined;
|
||||
const sectionInfo = findSiteSpaceBy(structure, (ss) => ss.id === siteSpace.id);
|
||||
const { siteSection, siteSectionGroup } = sectionInfo ?? {};
|
||||
|
||||
for (const { page, ancestors } of getIndexablePagesWithAncestors(revision.pages)) {
|
||||
const cacheKey = `${siteSpace.id}:${page.id}`;
|
||||
if (seen.has(cacheKey)) continue;
|
||||
seen.add(cacheKey);
|
||||
|
||||
const breadcrumbs: Breadcrumb[] = [
|
||||
siteSectionGroup
|
||||
? {
|
||||
label: getLocalizedTitle(siteSectionGroup, lang),
|
||||
icon: siteSectionGroup.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
siteSection
|
||||
? {
|
||||
label: getLocalizedTitle(siteSection, lang),
|
||||
icon: siteSection.icon ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
...ancestors.map((a) => ({
|
||||
label: a.title,
|
||||
icon: a.icon ?? undefined,
|
||||
emoji: a.emoji ?? undefined,
|
||||
})),
|
||||
].filter((c) => c !== undefined);
|
||||
|
||||
pages.push({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
pathname: forkedLinker.toPathForPage({ pages: revision.pages, page }),
|
||||
siteSpaceId: siteSpace.id,
|
||||
lang,
|
||||
icon: page.icon ?? undefined,
|
||||
emoji: page.emoji ?? undefined,
|
||||
description: page.description ?? undefined,
|
||||
breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only cache the search index on the client if the site is public or unlisted, to avoid leaking information about private sites.
|
||||
// For private sites, we set `Cache-Control: no-store` to prevent caching at all.
|
||||
const shouldCacheOnClient =
|
||||
context.site.visibility === SiteVisibility.Public ||
|
||||
context.site.visibility === SiteVisibility.Unlisted;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
// We include a version number in the response to allow future changes to the format without breaking clients that might have cached the old format.
|
||||
version: 1,
|
||||
pages,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Cache for 5 minutes on the client, 1 day on the CDN, and allow serving stale content while revalidating for 1 day
|
||||
'Cache-Control': shouldCacheOnClient
|
||||
? 'public, max-age=300, s-maxage=86400, stale-while-revalidate=86400'
|
||||
: 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getIconSymbol } from '@/lib/icons/symbols';
|
||||
import { getIconSymbolId } from '@gitbook/icons';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ style: string; icon: string }> }
|
||||
) {
|
||||
const { style, icon } = await params;
|
||||
const symbol = await getIconSymbol(style, icon, getIconSymbolId(style, icon));
|
||||
|
||||
if (!symbol) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Symbol not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(symbol.document, {
|
||||
headers: {
|
||||
'content-type': 'image/svg+xml; charset=utf-8',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
@@ -29,6 +30,7 @@ export const ConfirmControlDef = createAIControl({
|
||||
|
||||
function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
|
||||
const { label, icon, onSubmit } = props;
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
@@ -36,17 +38,18 @@ function ConfirmControl(props: GetAIControlProps<typeof ConfirmControlDef>) {
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'cancelled' });
|
||||
}}
|
||||
variant="secondary"
|
||||
truncate={false}
|
||||
variant="blank"
|
||||
icon="xmark"
|
||||
label="Cancel"
|
||||
label={tString(language, 'cancel')}
|
||||
/>
|
||||
<Button
|
||||
data-testid="ai-chat-tool-confirm-accept"
|
||||
onClick={() => {
|
||||
onSubmit({ result: 'confirmed' });
|
||||
}}
|
||||
className="flex-1"
|
||||
variant="primary"
|
||||
truncate={false}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Input } from '@/components/primitives';
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, IconStyle } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { AIToolContainer } from './common';
|
||||
import { type GetAIControlProps, createAIControl } from './helpers';
|
||||
|
||||
const OTHER_OPTION_ID = '$other';
|
||||
|
||||
export const MultiChoiceControlOutputSchema = z.object({
|
||||
ids: z.array(z.string()).describe('The identifiers of the options selected by the user.'),
|
||||
labels: z.array(z.string()).describe('The labels of the options selected by the user.'),
|
||||
input: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The custom text entered by the user when "Other" is enabled and selected among the choices.'
|
||||
),
|
||||
});
|
||||
|
||||
export const MultiChoiceControlDef = createAIControl({
|
||||
name: 'multi-choice',
|
||||
exposeAsTool: true,
|
||||
description:
|
||||
'Use this control whenever you need the user to choose one or more options from a predefined list. Never add an "Other" option to the `options` array, use the `allowOther` property instead.',
|
||||
inputSchema: z.object({
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide the question or instruction that tells the user what choices they need to make.'
|
||||
),
|
||||
options: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'Provide a unique, stable identifier for this option. This is the value returned to the agent when the user selects it.'
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.describe('Provide the short label the user sees for this option.'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optionally provide supporting details to help the user understand this option.'
|
||||
),
|
||||
})
|
||||
.describe('Define one selectable option the user can pick.')
|
||||
)
|
||||
.describe(
|
||||
'Provide the list of options the user can choose from. The user may select one or more.'
|
||||
),
|
||||
allowOther: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true to let the user select an "Other" option and enter a custom text response.'
|
||||
),
|
||||
}),
|
||||
outputSchema: MultiChoiceControlOutputSchema,
|
||||
render: (props) => {
|
||||
return <MultiChoiceControl {...props} />;
|
||||
},
|
||||
});
|
||||
|
||||
function MultiChoiceControl(props: GetAIControlProps<typeof MultiChoiceControlDef>) {
|
||||
const { prompt, options, allowOther, onSubmit } = props;
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [otherInput, setOtherInput] = React.useState('');
|
||||
const orderedOptionIds = [
|
||||
...options.map((option) => option.id),
|
||||
...(allowOther ? [OTHER_OPTION_ID] : []),
|
||||
];
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
const toggleOption = (id: string) => {
|
||||
setSelectedIds((current) =>
|
||||
current.includes(id)
|
||||
? current.filter((currentId) => currentId !== id)
|
||||
: orderedOptionIds.filter(
|
||||
(optionId) => optionId === id || current.includes(optionId)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const hasOtherSelected = selectedIds.includes(OTHER_OPTION_ID);
|
||||
const canSubmit = selectedIds.length > 0 && (!hasOtherSelected || otherInput.trim().length > 0);
|
||||
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
|
||||
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedIds.includes(option.id);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-testid={`ai-chat-tool-multi-choice-option-${option.id}`}
|
||||
onClick={() => {
|
||||
toggleOption(option.id);
|
||||
}}
|
||||
className={tcls(
|
||||
'text-left transition-colors',
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
|
||||
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original bg-primary-original text-contrast-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<Icon
|
||||
icon="check"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="size-3"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<p className="font-medium text-sm">{option.label}</p>
|
||||
{option.description ? (
|
||||
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||
{option.description}
|
||||
</p>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowOther ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ai-chat-tool-multi-choice-option-other"
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
toggleOption(OTHER_OPTION_ID);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={tString(language, 'form_other_prompt')}
|
||||
value={otherInput}
|
||||
onValueChange={setOtherInput}
|
||||
data-testid="ai-chat-tool-multi-choice-other-input"
|
||||
placeholder={tString(
|
||||
language,
|
||||
hasOtherSelected ? 'form_other_prompt' : 'form_other_field'
|
||||
)}
|
||||
className={tcls(
|
||||
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
|
||||
hasOtherSelected
|
||||
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
|
||||
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
sizing="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!hasOtherSelected) {
|
||||
toggleOption(OTHER_OPTION_ID);
|
||||
}
|
||||
}}
|
||||
leading={
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border bg-tint-base transition-colors',
|
||||
hasOtherSelected
|
||||
? 'border-primary-original bg-primary-original text-contrast-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
{hasOtherSelected ? (
|
||||
<Icon
|
||||
icon="check"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="size-3"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</ScrollContainer>
|
||||
|
||||
<Button
|
||||
data-testid="ai-chat-tool-multi-choice-submit"
|
||||
variant="primary"
|
||||
label={tString(language, 'submit')}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
ids: selectedIds,
|
||||
labels: selectedIds.map((id) =>
|
||||
id === OTHER_OPTION_ID
|
||||
? 'Other'
|
||||
: options.find((option) => option.id === id)?.label || ''
|
||||
),
|
||||
input: hasOtherSelected ? otherInput.trim() : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Input } from '@/components/primitives';
|
||||
import { ScrollContainer } from '@/components/primitives/ScrollContainer';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
@@ -11,6 +13,7 @@ const OTHER_OPTION_ID = '$other';
|
||||
|
||||
export const SingleChoiceControlOutputSchema = z.object({
|
||||
id: z.string().describe('The identifier of the option selected by the user.'),
|
||||
label: z.string().describe('The label of the option selected by the user.'),
|
||||
input: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -21,7 +24,7 @@ export const SingleChoiceControlDef = createAIControl({
|
||||
name: 'single-choice',
|
||||
exposeAsTool: true,
|
||||
description:
|
||||
'Use this control when you need the user to choose exactly one option from a predefined list.',
|
||||
'Use this control whenever you need the user to choose exactly one option from a predefined list. Important: NEVER write an "Other" choice yourself to the `options` array, pass the `allowOther` property as `true` to add it automatically instead.',
|
||||
inputSchema: z.object({
|
||||
prompt: z
|
||||
.string()
|
||||
@@ -44,7 +47,7 @@ export const SingleChoiceControlDef = createAIControl({
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optionally provide supporting details to help the user understand this option.'
|
||||
'Optionally provide supporting details to help the user understand this option. Keep it concise and do not repeat the label.'
|
||||
),
|
||||
})
|
||||
.describe('Define one selectable option the user can pick.')
|
||||
@@ -70,14 +73,16 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [otherInput, setOtherInput] = React.useState('');
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
const canSubmit =
|
||||
selectedId !== null &&
|
||||
(selectedId !== OTHER_OPTION_ID || (allowOther && otherInput.trim().length > 0));
|
||||
|
||||
return (
|
||||
<AIToolContainer className="flex w-full flex-col gap-2">
|
||||
<div className="no-scrollbar flex flex-1 flex-col gap-1 overflow-auto">
|
||||
<p className="mb-1 font-semibold text-sm">{prompt}</p>
|
||||
<p className="px-2 pt-1 font-semibold text-sm">{prompt}</p>
|
||||
<ScrollContainer orientation="vertical" contentClassName="flex flex-col gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedId === option.id;
|
||||
return (
|
||||
@@ -89,17 +94,18 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
|
||||
setSelectedId(option.id);
|
||||
}}
|
||||
className={tcls(
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
|
||||
'text-left transition-colors',
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl px-2 py-1 text-left transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original bg-primary-subtle text-tint-strong'
|
||||
: 'border-tint bg-tint-base hover:bg-tint-subtle'
|
||||
? 'bg-primary text-tint-strong contrast-more:bg-primary-active'
|
||||
: 'hover:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
|
||||
isSelected
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
@@ -115,7 +121,7 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
|
||||
<span className="min-w-0">
|
||||
<p className="font-medium text-sm">{option.label}</p>
|
||||
{option.description ? (
|
||||
<p className="mt-0.5 text-sm text-tint-subtle">
|
||||
<p className="mt-0.5 text-tint-subtle text-xs">
|
||||
{option.description}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -129,58 +135,58 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ai-chat-tool-single-choice-option-other"
|
||||
tabIndex={-1} // The input is already focusable, so prevent focus on the wrapper button
|
||||
onClick={() => {
|
||||
setSelectedId(OTHER_OPTION_ID);
|
||||
}}
|
||||
className={tcls(
|
||||
'circular-corners:rounded-3xl rounded-corners:rounded-xl border px-3 py-2 text-left transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'border-primary-original bg-primary-subtle text-tint-strong'
|
||||
: 'border-tint bg-tint-base hover:bg-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
label={tString(language, 'form_other_prompt')}
|
||||
value={otherInput}
|
||||
onValueChange={setOtherInput}
|
||||
data-testid="ai-chat-tool-single-choice-other-input"
|
||||
placeholder={tString(
|
||||
language,
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'form_other_prompt'
|
||||
: 'form_other_field'
|
||||
)}
|
||||
className={tcls(
|
||||
'grow gap-2 border-0 px-2 ring-inset **:placeholder:text-tint',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'bg-primary text-tint-strong hover:bg-primary contrast-more:bg-primary-active'
|
||||
: 'hover:not-focus-within:bg-tint contrast-more:hover:bg-tint-hover'
|
||||
)}
|
||||
sizing="small"
|
||||
leading={
|
||||
<span
|
||||
aria-hidden
|
||||
className={tcls(
|
||||
'size-2.5 rounded-full transition-colors',
|
||||
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border bg-tint-base transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'bg-primary-original'
|
||||
: 'bg-transparent'
|
||||
? 'border-primary-original'
|
||||
: 'border-tint-subtle'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<p className="font-medium text-sm">Other</p>
|
||||
{allowOther && selectedId === OTHER_OPTION_ID ? (
|
||||
<Input
|
||||
label="Enter your answer"
|
||||
value={otherInput}
|
||||
onValueChange={setOtherInput}
|
||||
data-testid="ai-chat-tool-single-choice-other-input"
|
||||
autoFocus
|
||||
className="w-full"
|
||||
sizing="small"
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'size-2.5 rounded-full transition-colors',
|
||||
selectedId === OTHER_OPTION_ID
|
||||
? 'bg-primary-original'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
<Button
|
||||
data-testid="ai-chat-tool-single-choice-submit"
|
||||
variant="primary"
|
||||
label="Submit answer"
|
||||
label={tString(language, 'submit')}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
if (!canSubmit || !selectedId) {
|
||||
@@ -190,12 +196,16 @@ function SingleChoiceControl(props: GetAIControlProps<typeof SingleChoiceControl
|
||||
if (selectedId === OTHER_OPTION_ID) {
|
||||
onSubmit({
|
||||
id: OTHER_OPTION_ID,
|
||||
label: 'Other',
|
||||
input: otherInput.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({ id: selectedId });
|
||||
onSubmit({
|
||||
id: selectedId,
|
||||
label: options.find((option) => option.id === selectedId)?.label || '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</AIToolContainer>
|
||||
|
||||
@@ -6,10 +6,10 @@ export function AIToolContainer(props: ComponentPropsWithRef<'div'>) {
|
||||
<div
|
||||
{...props}
|
||||
className={tcls(
|
||||
'min-h-0 animate-present-slow circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-subtle p-2',
|
||||
'min-h-0 shrink grow-0 animate-blur-in circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base p-2',
|
||||
props.className
|
||||
)}
|
||||
style={{ animationDelay: '0.5s', ...props.style }}
|
||||
style={{ animationDelay: '0.3s', ...props.style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { tString } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations/types';
|
||||
import type {
|
||||
AIStreamResponseToolCallPending,
|
||||
AIToolCallResult,
|
||||
@@ -16,6 +18,7 @@ type AIControlDefinition<
|
||||
createControl: (args: {
|
||||
context: AIUIToolContext;
|
||||
input: Input;
|
||||
language: TranslationLanguage;
|
||||
send: (result: Pick<AIToolCallResult, 'output' | 'summary'>) => Promise<void>;
|
||||
}) => AIControl<Name, Input, Output>;
|
||||
exposeAsTool: boolean;
|
||||
@@ -55,15 +58,15 @@ export function createAIControl<
|
||||
name: `ui--${def.name}`,
|
||||
description: def.description,
|
||||
inputSchema: zodToJsonSchema(def.inputSchema as any) as AIToolDefinition['inputSchema'],
|
||||
createControl: ({ context, input, send }) => {
|
||||
createControl: ({ context, input, language, send }) => {
|
||||
const props: AIControlProps<z.infer<InputSchema>, z.infer<OutputSchema>> = {
|
||||
...input,
|
||||
onSubmit: async (output) => {
|
||||
await send({
|
||||
output,
|
||||
summary: {
|
||||
icon: 'check',
|
||||
text: 'Submitted',
|
||||
icon: 'comment-check',
|
||||
text: tString(language, 'ai_control_submitted_answer'),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ConfirmControlDef } from './ConfirmControl';
|
||||
import { MultiChoiceControlDef } from './MultiChoiceControl';
|
||||
import { SingleChoiceControlDef } from './SingleChoiceControl';
|
||||
|
||||
const CONTROLS = [SingleChoiceControlDef, ConfirmControlDef];
|
||||
const CONTROLS = [SingleChoiceControlDef, MultiChoiceControlDef, ConfirmControlDef];
|
||||
|
||||
export type AnyAIControlTool = (typeof CONTROLS)[number];
|
||||
export type AnyAIControl = ReturnType<AnyAIControlTool['createControl']>;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
{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';
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
AIModel,
|
||||
type AIToolCallResult,
|
||||
type AIToolDefinition,
|
||||
SiteCoreChannelType,
|
||||
type SiteInsightsSession,
|
||||
} from '@gitbook/api';
|
||||
import { streamRenderAIMessage } from './api';
|
||||
@@ -35,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();
|
||||
|
||||
@@ -61,6 +60,11 @@ export async function* streamAIChatResponse({
|
||||
toolCall,
|
||||
tools,
|
||||
session,
|
||||
channel: {
|
||||
type: options?.asEmbeddable
|
||||
? SiteCoreChannelType.Embed
|
||||
: SiteCoreChannelType.Site,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import {
|
||||
AIMessageRole,
|
||||
AIMessageStepPhase,
|
||||
type AIStreamResponse,
|
||||
type AIStreamResponseToolCallPending,
|
||||
type AIToolCallResult,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import * as React from 'react';
|
||||
import { getVisitor, useTrackEvent } from '../Insights';
|
||||
import { getSession } from '../Insights/sessions';
|
||||
import { getInsightsSession, useTrackEvent } from '../Insights';
|
||||
import { useSetSearchState } from '../Search';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
@@ -24,8 +25,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 +226,7 @@ export function AIChatProvider(props: {
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -224,7 +243,7 @@ export function AIChatProvider(props: {
|
||||
|
||||
try {
|
||||
const result = await toolDef.execute(event.toolCall.input);
|
||||
streamResponse({
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
@@ -233,7 +252,7 @@ export function AIChatProvider(props: {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
streamResponse({
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
tool: event.toolCall.tool,
|
||||
toolCallId: event.toolCallId,
|
||||
@@ -257,10 +276,7 @@ export function AIChatProvider(props: {
|
||||
toolCall: input.toolCall,
|
||||
messageContext: messageContextRef.current,
|
||||
previousResponseId: globalState.getState().responseId ?? undefined,
|
||||
session: {
|
||||
sessionId: getSession().id,
|
||||
visitorId: (await getVisitor()).deviceId,
|
||||
},
|
||||
session: await getInsightsSession(),
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -322,6 +338,7 @@ export function AIChatProvider(props: {
|
||||
toolCallId: event.toolCallId,
|
||||
},
|
||||
input: event.toolCall.input as any,
|
||||
language,
|
||||
send: async (result) => {
|
||||
await streamResponse({
|
||||
toolCall: {
|
||||
@@ -350,6 +367,7 @@ export function AIChatProvider(props: {
|
||||
label: confirmation.label,
|
||||
icon: confirmation.icon,
|
||||
},
|
||||
language,
|
||||
send: async (result) => {
|
||||
const output = ConfirmControlOutputSchema.parse(
|
||||
result.output
|
||||
@@ -398,6 +416,11 @@ export function AIChatProvider(props: {
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -406,13 +429,13 @@ export function AIChatProvider(props: {
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
}
|
||||
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
globalState.setState((state) => ({
|
||||
@@ -558,3 +581,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,35 +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 ? '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
|
||||
? 'animate-blur-in-slow'
|
||||
: 'hidden'
|
||||
: 'animate-blur-out-slow'
|
||||
)}
|
||||
>
|
||||
{t(language, 'ai_chat_working')}
|
||||
</span>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
? 'animate-blur-out-slow'
|
||||
: 'animate-blur-in-slow'
|
||||
: 'hidden'
|
||||
)}
|
||||
>
|
||||
{t(language, 'ai_chat_thinking')}
|
||||
</span>
|
||||
{(
|
||||
[
|
||||
['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'
|
||||
: 'animate-blur-out-display-slow'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: status === candidateStatus ? '.3s' : undefined,
|
||||
}}
|
||||
>
|
||||
{subtitleKey ? t(language, key) : null}
|
||||
</span>
|
||||
))}
|
||||
</EmbeddableFrameSubtitle>
|
||||
);
|
||||
}
|
||||
@@ -226,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"
|
||||
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}`}
|
||||
@@ -274,21 +274,18 @@ export function AIChatBody(props: {
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex 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}
|
||||
|
||||
{chat.control ? (
|
||||
<AIChatControl control={chat.control} />
|
||||
) : (
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{chat.control ? <AIChatControl control={chat.control} /> : null}
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
const MOBILE_BREAKPOINT = 688; // 43rem, equal to Tailwind's @max-2xl container breakpoint
|
||||
|
||||
/**
|
||||
* Button to open/close the AI chat.
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -58,7 +59,10 @@ export function AIChatInput(props: {
|
||||
size: 'small',
|
||||
label: tString(language, 'send'),
|
||||
}}
|
||||
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
|
||||
className={tcls(
|
||||
chat.control ? 'animate-blur-out-display' : 'animate-blur-in-slow',
|
||||
'bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base'
|
||||
)}
|
||||
rows={1}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={
|
||||
@@ -69,7 +73,7 @@ export function AIChatInput(props: {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading}
|
||||
disabled={disabled || loading || chat.control !== null}
|
||||
aria-busy={loading}
|
||||
ref={inputRef}
|
||||
trailing={
|
||||
|
||||
@@ -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,25 +81,60 @@ 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'
|
||||
: 'origin-top-left text-tint-strong'
|
||||
? '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'
|
||||
: ''
|
||||
)}
|
||||
style={{
|
||||
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' : ''} />
|
||||
@@ -94,11 +148,11 @@ export function AIChatMessages(props: {
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.control ? (
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
@@ -107,7 +161,7 @@ export function AIChatMessages(props: {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-w-full max-w-full flex-col items-start gap-2 self-start"
|
||||
className="flex max-w-full flex-col items-start gap-2 self-start"
|
||||
data-testid="ai-chat-suggested-questions"
|
||||
>
|
||||
{suggestions.map((question, index) => (
|
||||
|
||||
@@ -15,7 +15,7 @@ export function AIChatFollowupSuggestions(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<div className="mt-auto flex grow flex-col">
|
||||
<div
|
||||
className="sticky bottom-0 mt-auto flex max-w-full flex-col items-start gap-2"
|
||||
data-testid="ai-chat-followup-suggestions"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function Announcement(props: {
|
||||
return (
|
||||
<AnnouncementBanner
|
||||
announcement={customization.announcement}
|
||||
locale={context.locale}
|
||||
contentRef={resolvedContentRef}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { setLocalStorageItem } from '@/lib/browser';
|
||||
import type { ResolvedContentRef } from '@/lib/references';
|
||||
import { getLocalizedMessage, getLocalizedTitle } from '@/lib/sites';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
import {
|
||||
type CustomizationAnnouncement,
|
||||
SiteInsightsLinkPosition,
|
||||
type TranslationLanguage,
|
||||
} from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { Button, Link } from '../primitives';
|
||||
@@ -16,11 +21,14 @@ import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
|
||||
*/
|
||||
export function AnnouncementBanner(props: {
|
||||
announcement: CustomizationAnnouncement;
|
||||
locale: TranslationLanguage | undefined;
|
||||
contentRef: ResolvedContentRef | null;
|
||||
}) {
|
||||
const { announcement, contentRef } = props;
|
||||
const { announcement, locale, contentRef } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const message = getLocalizedMessage(announcement, locale);
|
||||
const linkTitle = announcement.link ? getLocalizedTitle(announcement.link, locale) : undefined;
|
||||
|
||||
const hasLink = announcement.link && contentRef?.href;
|
||||
const closeable = announcement.style !== 'danger';
|
||||
@@ -61,7 +69,7 @@ export function AnnouncementBanner(props: {
|
||||
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
|
||||
/>
|
||||
<div>
|
||||
{announcement.message}
|
||||
{message}
|
||||
{hasLink ? (
|
||||
<div className={tcls(LinkStyles, style.link, 'ml-1 inline')}>
|
||||
{contentRef?.icon ? (
|
||||
@@ -69,9 +77,7 @@ export function AnnouncementBanner(props: {
|
||||
{contentRef?.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{announcement.link?.title && (
|
||||
<span className="mr-1">{announcement.link?.title}</span>
|
||||
)}
|
||||
{linkTitle ? <span className="mr-1">{linkTitle}</span> : null}
|
||||
<Icon
|
||||
icon={
|
||||
announcement.link?.to.kind === 'url'
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SkeletonHeading,
|
||||
SkeletonImage,
|
||||
SkeletonParagraph,
|
||||
SkeletonSmall,
|
||||
SkeletonUpdate,
|
||||
} from '@/components/primitives';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
@@ -142,7 +141,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'file':
|
||||
return <SkeletonHeading id={id} style={style} />;
|
||||
case 'paragraph':
|
||||
return <SkeletonSmall id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'list-ordered':
|
||||
case 'list-unordered':
|
||||
case 'list-tasks':
|
||||
@@ -153,7 +152,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'tabs':
|
||||
case 'stepper-step':
|
||||
case 'if':
|
||||
return <SkeletonParagraph id={id} style={style} />;
|
||||
return <SkeletonParagraph id={id} className={style} />;
|
||||
case 'expandable':
|
||||
case 'table':
|
||||
case 'swagger':
|
||||
@@ -173,7 +172,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'drawing':
|
||||
return <SkeletonImage id={id} style={style} />;
|
||||
case 'updates':
|
||||
return <SkeletonUpdate id={id} style={style} />;
|
||||
return <SkeletonUpdate id={id} className={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -193,10 +193,10 @@ function CodeBlockExpandable(props: {
|
||||
<Button
|
||||
icon={<ToggleChevron open={isExpanded} />}
|
||||
size="xsmall"
|
||||
variant="blank"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||
className="pointer-events-auto z-1 my-2 bg-tint! text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={controls}
|
||||
>
|
||||
|
||||
@@ -41,14 +41,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
ref={ref}
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls(
|
||||
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
|
||||
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
|
||||
style
|
||||
)}
|
||||
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
|
||||
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
|
||||
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
{title ? (
|
||||
<div
|
||||
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
|
||||
@@ -63,43 +63,45 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={codeId}
|
||||
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base p-2 text-tint-strong [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
|
||||
title && 'rounded-ss-none!'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
}}
|
||||
>
|
||||
<code
|
||||
id={codeId}
|
||||
<div className="relative">
|
||||
<CopyCodeButton
|
||||
codeId={codeId}
|
||||
style="absolute top-2 right-2 z-2 self-start justify-self-end font-sans leading-none opacity-0 backdrop-blur-md group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap',
|
||||
'[[aria-expanded=false]_&]:mask-b-from-50%'
|
||||
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base py-2 text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base print:overflow-visible',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
|
||||
title && 'rounded-ss-none!'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
}}
|
||||
>
|
||||
{theme.lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
key={index}
|
||||
line={line}
|
||||
isLast={index === theme.lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
<code
|
||||
id={codeId}
|
||||
className={tcls(
|
||||
'table max-h-full w-fit min-w-full [counter-reset:line] print:max-h-none print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap',
|
||||
'[[aria-expanded=false]_&]:mask-b-from-50%'
|
||||
)}
|
||||
>
|
||||
{theme.lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
key={index}
|
||||
line={line}
|
||||
isLast={index === theme.lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,63 +3,117 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
import { Loading } from '@/components/primitives/Loading';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import Panzoom from '@panzoom/panzoom';
|
||||
import type { RenderResult } from 'mermaid';
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { getPlainCodeBlock } from './highlight';
|
||||
|
||||
/**
|
||||
* Used to render a Mermaid diagram from a CodeBlock.
|
||||
*/
|
||||
export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style } = props;
|
||||
const { block, mode, style } = props;
|
||||
const source = getPlainCodeBlock(block);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const darkMode = resolvedTheme === 'dark';
|
||||
const id = useSafeId();
|
||||
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
|
||||
const shouldRender = mode === 'print' || hasBeenInViewport;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = diagramRef.current;
|
||||
if (!container) {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!container || !wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let cleanupPanZoom: (() => void) | undefined;
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
setPanZoom(null);
|
||||
container.innerHTML = '';
|
||||
|
||||
renderMermaidDiagram({
|
||||
container,
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
const cancelScheduledRender = scheduleMermaidWork(() => {
|
||||
enqueueMermaidRender(async () => {
|
||||
if (cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderMermaidDiagram({
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
.then((result) => {
|
||||
if (!result || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.svg;
|
||||
if (container.querySelector('svg')) {
|
||||
result.bindFunctions?.(container);
|
||||
}
|
||||
|
||||
cleanupPanZoom = initPanzoom({
|
||||
container,
|
||||
wrapper,
|
||||
onInit: setPanZoom,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelScheduledRender();
|
||||
cleanupPanZoom?.();
|
||||
setPanZoom(null);
|
||||
};
|
||||
}, [source, id, darkMode]);
|
||||
}, [source, id, darkMode, shouldRender]);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', style)} contentEditable={false}>
|
||||
<div className={isLoading ? 'invisible absolute inset-x-0' : undefined}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={tcls('group/mermaid relative', style)}
|
||||
contentEditable={false}
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={
|
||||
isLoading
|
||||
? 'invisible absolute inset-x-0 overflow-hidden'
|
||||
: 'cursor-grab overflow-hidden active:cursor-grabbing'
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
@@ -70,23 +124,18 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
<Loading className="h-8 w-8" />
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderMermaidDiagram(args: {
|
||||
container: HTMLElement;
|
||||
source: string;
|
||||
id: string;
|
||||
darkMode: boolean;
|
||||
}) {
|
||||
const { container, source, id, darkMode } = args;
|
||||
const [{ default: mermaid }, { default: zenuml }] = await Promise.all([
|
||||
import('mermaid'),
|
||||
import('@mermaid-js/mermaid-zenuml'),
|
||||
]);
|
||||
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
}): Promise<RenderResult> {
|
||||
const { source, id, darkMode } = args;
|
||||
const { mermaid } = await loadMermaid();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
@@ -95,9 +144,141 @@ async function renderMermaidDiagram(args: {
|
||||
theme: darkMode ? 'dark' : undefined,
|
||||
});
|
||||
|
||||
const { svg, bindFunctions } = await mermaid.render(`mermaid-diagram-${id}`, source, container);
|
||||
container.innerHTML = svg;
|
||||
bindFunctions?.(container);
|
||||
const renderContainer = createMermaidRenderContainer();
|
||||
|
||||
try {
|
||||
return await mermaid.render(`mermaid-diagram-${id}`, source, renderContainer);
|
||||
} finally {
|
||||
renderContainer.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid measures labels while rendering, so the temporary render target must be
|
||||
* connected to the document. Keep it fixed and contained so those layout reads do
|
||||
* not walk the visible document flow.
|
||||
*/
|
||||
function createMermaidRenderContainer() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(container.style, {
|
||||
contain: 'strict',
|
||||
height: '100vh',
|
||||
isolation: 'isolate',
|
||||
left: '0',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
visibility: 'hidden',
|
||||
width: '100vw',
|
||||
zIndex: '-1',
|
||||
});
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
let mermaidLoadPromise: Promise<{
|
||||
mermaid: typeof import('mermaid')['default'];
|
||||
}> | null = null;
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidLoadPromise) {
|
||||
mermaidLoadPromise = Promise.all([import('mermaid'), import('@mermaid-js/mermaid-zenuml')])
|
||||
.then(async ([{ default: mermaid }, { default: zenuml }]) => {
|
||||
await mermaid.registerExternalDiagrams([zenuml]);
|
||||
return { mermaid };
|
||||
})
|
||||
.catch((error) => {
|
||||
mermaidLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return mermaidLoadPromise;
|
||||
}
|
||||
|
||||
let mermaidRenderQueue = Promise.resolve();
|
||||
|
||||
function enqueueMermaidRender<T>(task: () => Promise<T>) {
|
||||
const result = mermaidRenderQueue.then(task, task);
|
||||
mermaidRenderQueue = result.catch(() => {}).then(waitForNextFrame);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function waitForNextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleMermaidWork(callback: () => void) {
|
||||
const win = window as WindowWithIdleCallback;
|
||||
let cancelled = false;
|
||||
|
||||
if (win.requestIdleCallback && win.cancelIdleCallback) {
|
||||
const handle = win.requestIdleCallback(
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
win.cancelIdleCallback?.(handle);
|
||||
};
|
||||
}
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}
|
||||
|
||||
type WindowWithIdleCallback = Window & {
|
||||
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize panzoom on the diagram container.
|
||||
*/
|
||||
function initPanzoom(args: {
|
||||
container: HTMLElement;
|
||||
wrapper: HTMLElement;
|
||||
onInit: (instance: ReturnType<typeof Panzoom> | null) => void;
|
||||
}): () => void {
|
||||
const { container, wrapper, onInit } = args;
|
||||
|
||||
const instance = Panzoom(container, {
|
||||
maxScale: 5,
|
||||
minScale: 0.5,
|
||||
cursor: 'grab',
|
||||
panOnlyWhenZoomed: true,
|
||||
});
|
||||
|
||||
onInit(instance);
|
||||
|
||||
wrapper.addEventListener('wheel', instance.zoomWithWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
wrapper.removeEventListener('wheel', instance.zoomWithWheel);
|
||||
instance.destroy();
|
||||
onInit(null);
|
||||
};
|
||||
}
|
||||
|
||||
function useSafeId() {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import type { PanzoomObject } from '@panzoom/panzoom';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
|
||||
const PAN_STEP = 50;
|
||||
|
||||
/**
|
||||
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
|
||||
*/
|
||||
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
|
||||
const { panZoom } = props;
|
||||
const btnProps = {
|
||||
variant: 'secondary' as const,
|
||||
size: 'xsmall' as const,
|
||||
iconOnly: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5 opacity-0 transition-opacity duration-150 group-focus-within/mermaid:pointer-events-auto group-focus-within/mermaid:opacity-100 group-hover/mermaid:opacity-100 motion-reduce:transition-none">
|
||||
{/* Row 1: empty, pan up, zoom in */}
|
||||
<div />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-up"
|
||||
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="plus" onClick={() => panZoom.zoomIn()} />
|
||||
{/* Row 2: pan left, reset, pan right */}
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-left"
|
||||
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="refresh" onClick={() => panZoom.reset()} />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-right"
|
||||
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
{/* Row 3: empty, pan down, zoom out */}
|
||||
<div />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="chevron-down"
|
||||
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button {...btnProps} icon="minus" onClick={() => panZoom.zoomOut()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,5 +7,5 @@ import type { BlockProps } from './Block';
|
||||
export function Divider(props: BlockProps<DocumentBlockDivider>) {
|
||||
const { style } = props;
|
||||
|
||||
return <hr className={tcls(style, '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',
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { isSiteAuthLoginHref } from '@/lib/auth-login-link';
|
||||
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
|
||||
import * as api from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
import { SiteAuthLoginButton } from '../SiteAuth/SiteAuthLoginLink';
|
||||
import { Button, type ButtonProps } from '../primitives';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { InlineActionButton } from './InlineActionButton';
|
||||
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
|
||||
|
||||
export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
const { inline } = props;
|
||||
const { inline, context } = props;
|
||||
|
||||
const buttonProps: ButtonProps = {
|
||||
label: inline.data.label,
|
||||
@@ -17,7 +20,8 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
if ('action' in inline.data && 'query' in inline.data.action) {
|
||||
// In print/PDF mode, skip interactive action buttons (AI/search providers are not mounted).
|
||||
if (context.mode !== 'print' && 'action' in inline.data && 'query' in inline.data.action) {
|
||||
return (
|
||||
<InlineActionButton
|
||||
action={inline.data.action.action}
|
||||
@@ -57,21 +61,27 @@ export async function InlineLinkButton(
|
||||
const href =
|
||||
resolved?.href ??
|
||||
(inline.data.ref ? resolveContentRefFallback(inline.data.ref)?.href : undefined);
|
||||
const sharedProps: React.ComponentProps<typeof Button> = {
|
||||
...buttonProps,
|
||||
insights: {
|
||||
type: 'link_click' as const,
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: api.SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
},
|
||||
href,
|
||||
disabled: href === undefined,
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: api.SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
href={href}
|
||||
disabled={href === undefined}
|
||||
/>
|
||||
);
|
||||
const button =
|
||||
href &&
|
||||
context.contentContext &&
|
||||
isSiteAuthLoginHref(context.contentContext.linker, href) ? (
|
||||
<SiteAuthLoginButton {...sharedProps} />
|
||||
) : (
|
||||
<Button {...sharedProps} />
|
||||
);
|
||||
|
||||
if (inline.data.ref && !resolved) {
|
||||
return <NotFoundRefHoverCard context={context}>{button}</NotFoundRefHoverCard>;
|
||||
|
||||
@@ -50,6 +50,10 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
||||
props,
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllResponses:
|
||||
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
|
||||
expandAllModelSections:
|
||||
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
|
||||
})}
|
||||
className="openapi-block"
|
||||
/>
|
||||
|
||||
@@ -46,12 +46,13 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
|
||||
|
||||
return (
|
||||
<BaseOpenAPISchemas
|
||||
schemas={data.schemas}
|
||||
data={data}
|
||||
grouped={block.data.grouped}
|
||||
context={getOpenAPIContext({
|
||||
props,
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllModelSections: data['x-expandAllModelSections'],
|
||||
})}
|
||||
className="openapi-block"
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,10 @@ async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||
props,
|
||||
specUrl: publicURL,
|
||||
context: context.contentContext,
|
||||
expandAllResponses:
|
||||
data.operation['x-expandAllResponses'] ?? data['x-expandAllResponses'],
|
||||
expandAllModelSections:
|
||||
data.operation['x-expandAllModelSections'] ?? data['x-expandAllModelSections'],
|
||||
})}
|
||||
className="openapi-block"
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { JSONDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { PlainCodeBlock } from '../CodeBlock';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
@@ -26,16 +24,19 @@ export function getOpenAPIContext(args: {
|
||||
props: BlockProps<AnyOpenAPIOperationsBlock | OpenAPISchemasBlock | OpenAPIWebhookBlock>;
|
||||
specUrl: string | null;
|
||||
context: GitBookAnyContext | undefined;
|
||||
expandAllResponses?: boolean;
|
||||
expandAllModelSections?: boolean;
|
||||
}): OpenAPIContextInput {
|
||||
const { props, specUrl, context } = args;
|
||||
const { props, specUrl, context, expandAllResponses, expandAllModelSections } = args;
|
||||
const { block } = props;
|
||||
|
||||
const customizationLocale = context ? getSpaceLocale(context) : DEFAULT_LOCALE;
|
||||
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
|
||||
|
||||
const proxyUrl = context
|
||||
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
|
||||
: undefined;
|
||||
const proxyUrl =
|
||||
context && props.context.mode !== 'print'
|
||||
? context.linker.toAbsoluteURL(context.linker.toPathInSite('~scalar/proxy'))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
specUrl,
|
||||
@@ -75,12 +76,7 @@ export function getOpenAPIContext(args: {
|
||||
ancestorBlocks={props.ancestorBlocks}
|
||||
isEstimatedOffscreen={props.isEstimatedOffscreen}
|
||||
context={props.context}
|
||||
style={tcls([
|
||||
headingProps.deprecated ? 'line-through' : undefined,
|
||||
headingProps.deprecated || !!headingProps.stability
|
||||
? '[&>div]:pt-0'
|
||||
: undefined,
|
||||
])}
|
||||
style={headingProps.deprecated ? 'line-through' : undefined}
|
||||
block={{
|
||||
object: 'block',
|
||||
key: `${block.key}-heading`,
|
||||
@@ -97,7 +93,8 @@ export function getOpenAPIContext(args: {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
defaultInteractiveOpened: props.context.mode === 'print',
|
||||
expandAllResponses: expandAllResponses || props.context.mode === 'print',
|
||||
expandAllModelSections: expandAllModelSections || props.context.mode === 'print',
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
locale,
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
/* Layout Components */
|
||||
.openapi-operation,
|
||||
.openapi-schemas,
|
||||
.openapi-webhook {
|
||||
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
|
||||
.openapi-webhook,
|
||||
.openapi-schemas-single {
|
||||
@apply flex-1 flex flex-col gap-6 mb-14 min-w-0;
|
||||
}
|
||||
|
||||
.openapi-schemas {
|
||||
@apply flex flex-col mb-14 gap-0 flex-1;
|
||||
.openapi-schemas:not(.openapi-schemas-single) {
|
||||
@apply gap-0;
|
||||
}
|
||||
|
||||
.openapi-schemas-title {
|
||||
@@ -33,6 +34,10 @@
|
||||
@apply flex flex-row gap-2 mt-[0.75em];
|
||||
}
|
||||
|
||||
.openapi-summary-tags + .heading {
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
.openapi-deprecated,
|
||||
.openapi-stability {
|
||||
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
@@ -144,7 +149,7 @@
|
||||
}
|
||||
|
||||
.openapi-column-preview {
|
||||
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:py-6 sticky max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
|
||||
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:pt-6 sticky self-start max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset);
|
||||
}
|
||||
|
||||
.openapi-column-preview-body {
|
||||
@@ -233,7 +238,7 @@
|
||||
}
|
||||
|
||||
.openapi-schema-type {
|
||||
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem];
|
||||
@apply text-tint select-text text-[0.813rem] font-mono [word-spacing:-0.25rem] whitespace-normal;
|
||||
}
|
||||
|
||||
.openapi-schema-type:only-child {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,8 +4,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import type { TableRecordKV, TableViewProps } from './Table';
|
||||
import { getColumnWidth } from './ViewGrid';
|
||||
import styles from './table.module.css';
|
||||
import { getColumnWidth } from './layout';
|
||||
import { getColumnVerticalAlignment } from './utils';
|
||||
|
||||
export function RecordRow(
|
||||
@@ -16,9 +15,19 @@ 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={styles.row} role="row">
|
||||
<div
|
||||
className={tcls(
|
||||
'group/row flex',
|
||||
'border-tint-subtle',
|
||||
'transition-colors',
|
||||
'hover:bg-tint-hover'
|
||||
)}
|
||||
role="row"
|
||||
>
|
||||
{view.columns.map((column) => {
|
||||
const columnWidth = getColumnWidth({
|
||||
column,
|
||||
@@ -26,6 +35,7 @@ export function RecordRow(
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
});
|
||||
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
|
||||
// @ts-expect-error
|
||||
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
|
||||
|
||||
@@ -33,7 +43,13 @@ export function RecordRow(
|
||||
<div
|
||||
key={column}
|
||||
role="cell"
|
||||
className={tcls(styles.cell)}
|
||||
className={tcls(
|
||||
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
|
||||
'border-tint-subtle',
|
||||
isStickyFirstColumnCell
|
||||
? 'sticky left-0 z-10 bg-tint-base group-hover/row:bg-tint-hover'
|
||||
: undefined
|
||||
)}
|
||||
style={{
|
||||
width: columnWidth,
|
||||
minWidth: columnWidth || '100px',
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
interface StickyViewGridProps {
|
||||
className?: string;
|
||||
header?: ReactNode;
|
||||
stickyHeader?: boolean;
|
||||
tableClassName?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StickyViewGrid(props: StickyViewGridProps) {
|
||||
if (props.stickyHeader) {
|
||||
return <StickyHeaderOverlayScrollGrid {...props} />;
|
||||
}
|
||||
return <DefaultHeaderScrollGrid {...props} />;
|
||||
}
|
||||
|
||||
function DefaultHeaderScrollGrid({
|
||||
className,
|
||||
header,
|
||||
tableClassName,
|
||||
children,
|
||||
}: StickyViewGridProps) {
|
||||
const resolvedTableClassName = tableClassName ?? 'w-fit';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle">
|
||||
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
|
||||
<div className={tcls('flex', 'flex-col', resolvedTableClassName)}>
|
||||
{header}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickyHeaderOverlayScrollGrid({
|
||||
className,
|
||||
header,
|
||||
tableClassName,
|
||||
children,
|
||||
}: StickyViewGridProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const stickyHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const bodyScrollRef = useRef<HTMLDivElement>(null);
|
||||
const bodyTableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onStickyHeaderWheel = useCallback((event: WheelEvent) => {
|
||||
const bodyScrollElement = bodyScrollRef.current;
|
||||
if (!bodyScrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalDelta = event.deltaX || (event.shiftKey && event.deltaY ? event.deltaY : 0);
|
||||
if (horizontalDelta === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
bodyScrollElement.scrollLeft += horizontalDelta;
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stickyHeaderElement = stickyHeaderRef.current;
|
||||
if (!stickyHeaderElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
stickyHeaderElement.addEventListener('wheel', onStickyHeaderWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
stickyHeaderElement.removeEventListener('wheel', onStickyHeaderWheel);
|
||||
};
|
||||
}, [onStickyHeaderWheel]);
|
||||
|
||||
const syncStickyLayout = useCallback(() => {
|
||||
const rootElement = rootRef.current;
|
||||
const bodyScrollElement = bodyScrollRef.current;
|
||||
const bodyTableElement = bodyTableRef.current;
|
||||
if (!rootElement || !bodyScrollElement || !bodyTableElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
rootElement.style.setProperty(
|
||||
'--table-sticky-scroll-left',
|
||||
`${-bodyScrollElement.scrollLeft}px`
|
||||
);
|
||||
rootElement.style.setProperty(
|
||||
'--table-sticky-table-width',
|
||||
`${bodyTableElement.scrollWidth}px`
|
||||
);
|
||||
rootElement.dataset.scrollable = `${
|
||||
bodyScrollElement.scrollWidth > bodyScrollElement.clientWidth + 1
|
||||
}`;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncStickyLayout();
|
||||
}, [syncStickyLayout]);
|
||||
|
||||
useScrollListener(syncStickyLayout, bodyScrollRef);
|
||||
|
||||
useEffect(() => {
|
||||
const bodyScrollElement = bodyScrollRef.current;
|
||||
const bodyTableElement = bodyTableRef.current;
|
||||
|
||||
if (!bodyScrollElement && !bodyTableElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncStickyLayout);
|
||||
|
||||
if (bodyScrollElement) {
|
||||
resizeObserver.observe(bodyScrollElement);
|
||||
}
|
||||
|
||||
if (bodyTableElement) {
|
||||
resizeObserver.observe(bodyTableElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [syncStickyLayout]);
|
||||
|
||||
const resolvedTableClassName = tableClassName ?? 'w-fit';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
|
||||
data-scrollable="false"
|
||||
>
|
||||
{header ? (
|
||||
<div
|
||||
ref={stickyHeaderRef}
|
||||
className={tcls(
|
||||
'sticky z-20 w-full min-w-0 max-w-full overflow-hidden rounded-t-[inherit]',
|
||||
'[top:var(--toc-top-offset,var(--outline-top-offset,0px))]'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={bodyScrollRef}
|
||||
className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle group-data-[scrollable=true]/table:mx-px group-data-[scrollable=true]/table:rounded-none group-data-[scrollable=true]/table:border-0"
|
||||
>
|
||||
<div
|
||||
ref={bodyTableRef}
|
||||
className={tcls('flex', 'flex-col', resolvedTableClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
import { isBlockOffscreen } from '../utils';
|
||||
import { StickyViewGrid } from './StickyViewGrid';
|
||||
import { ViewCards } from './ViewCards';
|
||||
import { ViewGrid } from './ViewGrid';
|
||||
import { ViewGrid, ViewGridHeader } from './ViewGrid';
|
||||
import { getViewGridLayout, hasVisibleHeader } from './layout';
|
||||
|
||||
export type TableRecordKV = [string, DocumentTableRecord];
|
||||
|
||||
@@ -15,7 +19,7 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
|
||||
}
|
||||
|
||||
export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
const { block, ancestorBlocks, document } = props;
|
||||
const { block, ancestorBlocks, document, context, style } = props;
|
||||
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
|
||||
|
||||
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
|
||||
@@ -32,15 +36,78 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
case 'grid':
|
||||
case 'grid': {
|
||||
const gridProps = {
|
||||
...props,
|
||||
view: block.data.view,
|
||||
isOffscreen,
|
||||
records,
|
||||
};
|
||||
const { tableWidth } = getViewGridLayout({
|
||||
block,
|
||||
view: block.data.view,
|
||||
mode: context.mode,
|
||||
});
|
||||
const tableContainerClassName =
|
||||
tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
|
||||
const withHeader = hasVisibleHeader(block, block.data.view);
|
||||
const withStickyHeader =
|
||||
withHeader && context.mode !== 'print' && block.data.view.stickyHeader === true;
|
||||
const withStickyFirstColumn =
|
||||
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
|
||||
|
||||
if (withStickyHeader || withStickyFirstColumn) {
|
||||
return (
|
||||
<StickyViewGrid
|
||||
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
|
||||
stickyHeader={withStickyHeader}
|
||||
tableClassName={tableContainerClassName}
|
||||
header={
|
||||
withHeader ? (
|
||||
<ViewGridHeader
|
||||
{...gridProps}
|
||||
tableClassName={tableContainerClassName}
|
||||
className={tcls(
|
||||
withStickyHeader
|
||||
? [
|
||||
'mb-0 border-t border-r border-l',
|
||||
'group-data-[scrollable=false]/table:mb-1',
|
||||
'group-data-[scrollable=false]/table:rounded-b-lg',
|
||||
'group-data-[scrollable=true]/table:border-t-0',
|
||||
'group-data-[scrollable=true]/table:border-x-0',
|
||||
]
|
||||
: undefined
|
||||
)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
|
||||
</StickyViewGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewGrid
|
||||
view={block.data.view}
|
||||
isOffscreen={isOffscreen}
|
||||
records={records}
|
||||
{...props}
|
||||
/>
|
||||
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle '
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-col', tableContainerClassName)}>
|
||||
{withHeader ? (
|
||||
<ViewGridHeader
|
||||
{...gridProps}
|
||||
tableClassName={tableContainerClassName}
|
||||
className={tableContainerClassName}
|
||||
/>
|
||||
) : null}
|
||||
<ViewGrid {...gridProps} tableClassName={tableContainerClassName} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertNever(block.data.view);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user