mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-26 04:07:07 +00:00
Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39150d5a8f | |||
| 509389c7e5 | |||
| 88b94b5d36 | |||
| 251630c49f | |||
| acf2719d06 | |||
| ddba8031ec | |||
| 72a1596ae8 | |||
| 5b36c59061 | |||
| 77c717c251 | |||
| 60310b4c83 | |||
| 6eabaecbad | |||
| 423bbd06d9 | |||
| 36f19c8018 | |||
| 2ecbdd8f62 | |||
| c727cc8b9d | |||
| 2c729d461e | |||
| d83ea4ce14 | |||
| bac8608e78 | |||
| 7a1236a3a5 | |||
| ed80bd9e7a | |||
| 7036b67307 | |||
| 6bd522b322 | |||
| 74fc3f74d7 | |||
| 3e384c679e | |||
| f1fa1061db | |||
| 4f754a73a3 | |||
| 62d6d28888 | |||
| fd73a9912b | |||
| 595435ac4a | |||
| aa9d41a1b6 | |||
| 30089b404c | |||
| 92b68560e1 | |||
| f158064817 | |||
| 2885a137f8 | |||
| 0ffb74a6f8 | |||
| b116a0a2a4 | |||
| b9bb535d52 | |||
| 257ca09a5e | |||
| f405fffa06 | |||
| 16a54b6bb9 | |||
| 526a928420 | |||
| c201abab5f | |||
| 3697dfefed | |||
| 287203d197 | |||
| 0b38f6c93b | |||
| 8ce64f4862 | |||
| 127ef54b0f | |||
| 67f31aaa7a | |||
| 51fef84d39 | |||
| 0c577a00de | |||
| 8b6dc6dbbd | |||
| 2e7ba40a25 | |||
| b70b8fd2ca | |||
| a9ba996953 | |||
| 47c8ccba6f | |||
| e9842feeef | |||
| f4b4fe15c1 | |||
| 06b9fd542a | |||
| 76077f9f6d | |||
| 7b65f7c0d5 | |||
| 37a9453917 | |||
| d994875bea | |||
| a9b5521f3a | |||
| 91fadb08ed | |||
| b55605f25e | |||
| 6ba5e54d19 | |||
| 56aeb19e49 | |||
| ebab8188f6 | |||
| 8064c95267 | |||
| 2cef2e02c8 | |||
| 8bfced2e0d | |||
| 61ee4e3637 | |||
| 927aa89213 | |||
| f29d1e2f16 | |||
| 3a9efb8aed | |||
| f71ab8426c | |||
| a3d1558f99 | |||
| ca53869e2d | |||
| 33598e665b | |||
| 9045ba5248 | |||
| 7061fc2223 | |||
| 0c9e3952b7 | |||
| 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 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Mark the AI Assistant chat as `aria-busy` while it is generating a response (including the follow-up suggestion phase), so assistive technologies are notified of the in-progress state.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix the AI assistant panel overflowing its frame when rendering single/multi-choice question forms with long option text.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Assistant: group all of a turn's tool calls behind a single "Explored with N tools" expandable instead of rendering a separate single-item expandable for each client-side tool call.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve Assistant form controls
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Redirect sitemap.md and .well-known/sitemap.md to llms.txt.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Improve AI agent detection and readability by outputting 200 for page not found.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Drive page actions ordering and default action from the new `pageActions.items` list, including the reorderable assistant action
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add a page reference chip when opening the Assistant from the page action, so the assistant is informed about the page the reader is currently on.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Handle circular oneOf with discriminator and allOf in OpenAPI schemas
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "🚀 Dev server",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "bun install && bun dev"],
|
||||
"port": 3000,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,10 @@ jobs:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
env:
|
||||
# The repo is managed by Bun, but publishing uses npm for provenance.
|
||||
# Keep Corepack from rejecting packageManager: bun@... when npm runs.
|
||||
COREPACK_ENABLE_PROJECT_SPEC: 0
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -26,9 +30,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 +50,3 @@ jobs:
|
||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/gitbook/worker-configuration.d.ts",
|
||||
"gitbook/tsconfig.json",
|
||||
"**/*.css"
|
||||
]
|
||||
},
|
||||
|
||||
+10
-7
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.9.2",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"turbo": "^2.9.18",
|
||||
"vercel": "50.37.3"
|
||||
},
|
||||
"packageManager": "bun@1.3.7",
|
||||
@@ -17,8 +17,9 @@
|
||||
"@types/react-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2",
|
||||
"axios": "1.8.4"
|
||||
"esbuild": "0.27.3",
|
||||
"axios": "1.8.4",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -42,7 +43,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.175.0",
|
||||
"@gitbook/api": "0.184.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
@@ -54,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.6": "patches/next@16.2.6.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 37a9453: Allow copying in embed
|
||||
- Updated dependencies [47c8ccb]
|
||||
- @gitbook/icons@0.5.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 88c38fa: Add Search tab to Docs Embed, refactor search
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8d2a95b: Docs Embed: Better support light/dark mode overrides
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+30
-47
@@ -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`
|
||||
@@ -285,44 +288,6 @@ Display GitBook branding in the embed. Defaults to true.
|
||||
trademark: true
|
||||
```
|
||||
|
||||
### Theming and `color-scheme` (CSS-first)
|
||||
|
||||
The embed supports both site-controlled theming and CSS-driven theming.
|
||||
|
||||
Precedence (highest → lowest):
|
||||
|
||||
- **Site mode / forced theme**: if the GitBook site does not support multiple themes, the embed is forced to the site’s default theme.
|
||||
- **Visitor preference**: if the site supports multiple themes and the visitor has previously selected a theme on the site, that preference is remembered.
|
||||
- **Browser/OS default**: otherwise the embed follows the browser/OS preference (`prefers-color-scheme`).
|
||||
|
||||
You can also drive the embed theme from CSS by setting `color-scheme` on the iframe element (or a parent it inherits from). When the iframe resolves to an explicit `color-scheme: light` or `color-scheme: dark`, that value is propagated into the embedded content so it renders consistently.
|
||||
|
||||
Standalone widget example:
|
||||
|
||||
```css
|
||||
/* Force the GitBook widget iframe to render in dark mode */
|
||||
#gitbook-widget-iframe {
|
||||
color-scheme: dark;
|
||||
}
|
||||
```
|
||||
|
||||
### `colorScheme`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
|
||||
Force the embed to render in a specific color scheme.
|
||||
|
||||
- **Type**: `'light' | 'dark'`
|
||||
- **Default**: `undefined` (follow site/visitor preference/system)
|
||||
|
||||
```javascript
|
||||
GitBook('configure', {
|
||||
colorScheme: 'dark'
|
||||
});
|
||||
```
|
||||
|
||||
To clear an override, omit `colorScheme` (or set it to `undefined` in JS) in a subsequent `configure` call.
|
||||
|
||||
### `actions`
|
||||
|
||||
Available in: Standalone script, NPM package, React components
|
||||
@@ -495,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.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
@@ -32,7 +32,7 @@
|
||||
"scripts": {
|
||||
"build": "bun run build-lib && bun run build-standalone",
|
||||
"build-lib": "tsdown",
|
||||
"build-standalone": "bun run ./scripts/build-standalone.ts",
|
||||
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"clean": "rm -rf ./dist",
|
||||
"unit": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { Features, transform } from 'lightningcss';
|
||||
|
||||
/**
|
||||
* Build the standalone embed script.
|
||||
* Bun's default CSS transpiler (which is a port of LightningCSS) strips out the native light-dark() function in favour of a polyfill.
|
||||
* Light-dark() is widely supported now, and the polyfill requires you to set a data attribute on the element instead of relying on plain CSS.
|
||||
* This script's purpose is to pass a feature flag to the CSS transpiler to keep the native light-dark() behavior.
|
||||
*/
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'bun',
|
||||
['build', 'src/standalone/index.ts', '--bundle', '--minify', '--outdir=standalone'],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`bun build failed with exit code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
|
||||
const sourceCSS = await readFile('src/standalone/style.css');
|
||||
const transformedCSS = transform({
|
||||
filename: 'src/standalone/style.css',
|
||||
code: sourceCSS,
|
||||
minify: true,
|
||||
// Keep native light-dark() behavior scoped to element color-scheme.
|
||||
exclude: Features.LightDark,
|
||||
});
|
||||
|
||||
await writeFile('standalone/index.css', transformedCSS.code);
|
||||
@@ -35,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);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
if (!iframe.contentWindow) {
|
||||
throw new Error('Iframe must have a content window');
|
||||
}
|
||||
|
||||
const allowTokens = iframe.allow
|
||||
.split(';')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!allowTokens.includes('clipboard-write')) {
|
||||
iframe.allow = [...allowTokens, 'clipboard-write'].join('; ');
|
||||
}
|
||||
|
||||
const channel = createChannel(iframe.contentWindow);
|
||||
|
||||
channel.receive((message: FrameToParentMessage) => {
|
||||
@@ -64,7 +74,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[];
|
||||
@@ -83,11 +83,6 @@ export type GitBookEmbeddableConfiguration = {
|
||||
* Display a close button inside the assistant.
|
||||
*/
|
||||
closeButton?: boolean;
|
||||
|
||||
/**
|
||||
* Force the embed to render in a specific color-scheme.
|
||||
*/
|
||||
colorScheme?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
@@ -72,7 +76,9 @@ export function GitBookFrame(props: GitBookFrameProps) {
|
||||
src={frameURL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
allow="clipboard-write"
|
||||
className={className}
|
||||
style={colorScheme ? { colorScheme } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ let widgetIframe: HTMLIFrameElement | undefined;
|
||||
let _client: GitBookClient | undefined;
|
||||
let _frame: GitBookFrameClient | undefined;
|
||||
let frameOptions: GetFrameURLOptions | undefined;
|
||||
let lastPushedColorScheme: 'light' | 'dark' | undefined;
|
||||
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
|
||||
button: {
|
||||
label: 'Ask',
|
||||
@@ -65,7 +64,7 @@ let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration
|
||||
greeting: { title: '', subtitle: '' },
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
tabs: ['assistant', 'docs'],
|
||||
tabs: ['assistant', 'search', 'docs'],
|
||||
trademark: true,
|
||||
};
|
||||
|
||||
@@ -86,43 +85,6 @@ widgetWindow.classList.add('hidden');
|
||||
document.body.appendChild(widgetButton);
|
||||
document.body.appendChild(widgetWindow);
|
||||
|
||||
/** Resolved `color-scheme` from the iframe element (incl. inheritance from `#gitbook-widget-window`). */
|
||||
function colorSchemeFromIframe(): 'light' | 'dark' | undefined {
|
||||
if (!widgetIframe) return undefined;
|
||||
const v = getComputedStyle(widgetIframe).colorScheme.trim().toLowerCase();
|
||||
return v === 'dark' || v === 'light' ? v : undefined;
|
||||
}
|
||||
|
||||
function pushColorSchemeToFrame() {
|
||||
if (!_frame) return;
|
||||
|
||||
const desired = frameConfiguration.colorScheme ?? colorSchemeFromIframe();
|
||||
if (desired === lastPushedColorScheme) return;
|
||||
lastPushedColorScheme = desired;
|
||||
|
||||
_frame.configure({
|
||||
...frameConfiguration,
|
||||
colorScheme: desired,
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-push when the host page or widget chrome changes theme (class/style) or OS preference changes. */
|
||||
function installHostThemeBridge() {
|
||||
const onChange = () => pushColorSchemeToFrame();
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)')?.addEventListener?.('change', onChange);
|
||||
|
||||
if (typeof MutationObserver === 'undefined') return;
|
||||
const observer = new MutationObserver(onChange);
|
||||
const opts: MutationObserverInit = {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
};
|
||||
observer.observe(document.documentElement, opts);
|
||||
// Safari: `color-scheme` on `#gitbook-widget-window` does not always surface on `<html>`.
|
||||
observer.observe(widgetWindow, opts);
|
||||
}
|
||||
installHostThemeBridge();
|
||||
|
||||
function getClient() {
|
||||
if (!_client) {
|
||||
throw new Error(
|
||||
@@ -137,9 +99,12 @@ function getIframe() {
|
||||
const client = getClient();
|
||||
|
||||
widgetIframe?.remove();
|
||||
lastPushedColorScheme = undefined;
|
||||
widgetIframe = document.createElement('iframe');
|
||||
widgetIframe.id = 'gitbook-widget-iframe';
|
||||
widgetIframe.allow = 'clipboard-write';
|
||||
if (frameOptions?.colorScheme) {
|
||||
widgetIframe.style.colorScheme = frameOptions.colorScheme;
|
||||
}
|
||||
widgetIframe.src = client.getFrameURL({
|
||||
...frameOptions,
|
||||
});
|
||||
@@ -150,8 +115,6 @@ function getIframe() {
|
||||
widgetWindow.classList.add('hidden');
|
||||
widgetButton.classList.remove('open');
|
||||
});
|
||||
|
||||
pushColorSchemeToFrame();
|
||||
}
|
||||
return { iframe: widgetIframe, frame: _frame };
|
||||
}
|
||||
@@ -229,12 +192,9 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
}
|
||||
}
|
||||
|
||||
const { frame } = getIframe();
|
||||
// Always propagate configuration updates, even when color-scheme doesn't change.
|
||||
frame.configure({
|
||||
getIframe().frame.configure({
|
||||
...frameConfiguration,
|
||||
});
|
||||
pushColorSchemeToFrame();
|
||||
break;
|
||||
}
|
||||
case 'clearChat':
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
--gitbook-widget-radius: .5rem;
|
||||
--gitbook-widget-text-size: 1rem;
|
||||
--gitbook-widget-text-color: #656973;
|
||||
--gitbook-widget-border-color: #e5e5e5;
|
||||
|
||||
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
|
||||
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
|
||||
--gitbook-widget-background-solid: #FFFFFF;
|
||||
--gitbook-widget-background-solid-hover: #FBFBFB;
|
||||
|
||||
--gitbook-widget-icon-size: 1.25rem;
|
||||
|
||||
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||
@@ -21,20 +29,13 @@
|
||||
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
#gitbook-widget-button,
|
||||
#gitbook-widget-window {
|
||||
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
|
||||
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
|
||||
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
|
||||
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
|
||||
--gitbook-widget-background-solid: light-dark(#FFFFFF, #f0f0f0);
|
||||
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #f0f0f0);
|
||||
|
||||
&[data-color-scheme=light] {
|
||||
color-scheme: light;
|
||||
}
|
||||
&[data-color-scheme=dark] {
|
||||
color-scheme: dark;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--gitbook-widget-text-color: #FFFFFF;
|
||||
--gitbook-widget-border-color: #202020;
|
||||
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
|
||||
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
|
||||
--gitbook-widget-background-solid: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6eabaec: Implement a getVariables function for ExpressionRuntime
|
||||
|
||||
## 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.3.0",
|
||||
"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 }) => {
|
||||
@@ -153,6 +195,49 @@ describe('ExpressionRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('getVariables', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'single variable',
|
||||
condition: 'isBetaUser === true',
|
||||
expectedVariables: ['isBetaUser'],
|
||||
},
|
||||
{
|
||||
scenario: 'multiple variables',
|
||||
condition: 'useProductA && !isBetaUser',
|
||||
expectedVariables: ['useProductA', 'isBetaUser'],
|
||||
},
|
||||
{
|
||||
scenario: 'member expression',
|
||||
condition: 'user.role === "admin"',
|
||||
expectedVariables: ['user.role'],
|
||||
},
|
||||
{
|
||||
scenario: 'nested member expression with method call',
|
||||
condition: 'products.includes("productA") && userSegments.alpha',
|
||||
expectedVariables: ['products.includes', 'userSegments.alpha'],
|
||||
},
|
||||
])(
|
||||
'should return variables used in expression: $scenario',
|
||||
({ condition, expectedVariables }) => {
|
||||
expect(runtime.getVariables(condition)).toEqual(expectedVariables);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'invalid syntax',
|
||||
condition: 't}=d',
|
||||
},
|
||||
{
|
||||
scenario: 'non conditional expression',
|
||||
condition: 'const a = 1;',
|
||||
},
|
||||
])('should return an empty array for invalid expressions: $scenario', ({ condition }) => {
|
||||
expect(runtime.getVariables(condition)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('generate', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { parse as parseLoose } from 'acorn-loose';
|
||||
import escodegen from 'escodegen';
|
||||
import evalESTreeExpr from 'eval-estree-expression';
|
||||
const { evaluate } = evalESTreeExpr;
|
||||
const { evaluate, variables } = evalESTreeExpr;
|
||||
|
||||
import { AutoComplete } from './autocomplete';
|
||||
import { ExpressionError } from './errors';
|
||||
@@ -162,6 +162,28 @@ export class ExpressionRuntime {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an expression, returns a list of variables used in the expression.
|
||||
*/
|
||||
public getVariables(expr: string): string[] {
|
||||
try {
|
||||
const parsed = this.parse(expr);
|
||||
|
||||
if (parsed.invalidNodes.length > 0) {
|
||||
throw new ExpressionError('Invalid nodes found when parsing');
|
||||
}
|
||||
|
||||
return variables(parsed.result, {
|
||||
functions: true,
|
||||
withMembers: true,
|
||||
generate: escodegen.generate,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.error(`Error while parsing expression ${expr} to get variables`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a binary expression and returns an @ExpressionParserResult.
|
||||
*/
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
+10
@@ -41,6 +41,16 @@ declare module 'eval-estree-expression' {
|
||||
options?: EvalESTreeExpressionOptions
|
||||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* Given an ESTree-compliant AST node, returns a list of variables used in the expression.
|
||||
* @param ast An object representing an ESTree-compliant AST node.
|
||||
* @param options Options for evaluation and compilation.
|
||||
*/
|
||||
export function variables<ASTNode>(
|
||||
ast: ASTNode,
|
||||
options?: EvalESTreeExpressionOptions
|
||||
): string[];
|
||||
|
||||
/**
|
||||
* Evaluates an ESTree expression synchronously against a given context.
|
||||
* @param expression - An object representing an ESTree-compliant AST node.
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
# gitbook
|
||||
|
||||
## 0.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- aa9d41a: Update llms.txt footer for GitBook sites
|
||||
- 4f754a7: Add GitBook information to footer of markdown pages
|
||||
- 2ecbdd8: Add a `navigateToPage` assistant tool that lets the AI open a documentation page on the reader's behalf. The page opens instantly (no confirmation) and is pushed to the browser history so the reader can navigate back.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7036b67: Fix custom assistants (e.g. Kapa, Inkeep) being opened twice when clicking "Ask with …" in the search bar.
|
||||
- 2885a13: Fix heading anchor alignment with heading text and translate its accessibility label.
|
||||
- b9bb535: Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view.
|
||||
- b116a0a: Fix OpenAPI CodeBlock scroll
|
||||
- d83ea4c: Improve a11y of list item collapse/extend toggle
|
||||
- 92b6856: Add Ask AI in CodeBlock
|
||||
- 7a1236a: Fix search results linking to the page instead of the section anchor when a section snippet is displayed.
|
||||
- b9bb535: Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger.
|
||||
- Updated dependencies [6eabaec]
|
||||
- Updated dependencies [f158064]
|
||||
- @gitbook/expr@1.3.0
|
||||
- @gitbook/react-openapi@1.5.13
|
||||
|
||||
## 0.26.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 67f31aa: Allow user-triggered AI assistants (ChatGPT-User, Claude-User, Perplexity-User) to read pages on non-indexable sites, so end-users can pull content into an LLM without an MCP connection. Search engines and training crawlers remain blocked.
|
||||
- 0c577a0: Allow CORS for the same root domain
|
||||
- 51fef84: Remove the date-gated header logo height logic and always apply the compact logo size to prevent size shifts between sections and translations.
|
||||
- f405fff: Add icon support in tab items
|
||||
- 3697dfe: Add support for hiding page actions from page layout options.
|
||||
- 127ef54: Add tag filtering controls for Updates blocks.
|
||||
|
||||
## 0.26.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b70b8fd: Fix llms-full.txt pagination to include pages from all site sections.
|
||||
- 6ba5e54: Support isComposing for search
|
||||
- 47c8ccb: Render icons as svg markup
|
||||
- a9ba996: Fix left/right aligment of images with caption
|
||||
- 56aeb19: Fix events tracked from the middleware missing some properties.
|
||||
- Updated dependencies [47c8ccb]
|
||||
- Updated dependencies [37a9453]
|
||||
- @gitbook/icons@0.5.0
|
||||
- @gitbook/embed@0.5.1
|
||||
- @gitbook/react-contentkit@0.7.16
|
||||
|
||||
## 0.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d3d5150: Add "Content-Signal" to "robots.txt"
|
||||
- 7595706: Allow AI agents to ask questions and get the answer in markdown when fetching with `?ask=<question>`.
|
||||
- a17ab65: Support variant selector in hidden section
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d1a2958: Skip AI questions when it matches the example provided to agents / crawlers.
|
||||
- e3aca9e: Fix embeddable search links
|
||||
- 4b78672: Fix links to other spaces/sections in the llms.txt.
|
||||
- d1a2958: Pass visitor session to Ask AI.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -110,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',
|
||||
@@ -222,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',
|
||||
|
||||
@@ -31,12 +31,24 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
setTimeToMorning,
|
||||
waitForAIChatResponse,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
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.`;
|
||||
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
|
||||
// single fixed search, a concise answer, and a fixed number of follow-ups. The
|
||||
// model is never perfectly deterministic, so `overrideAIResponse` still
|
||||
// normalizes the rendered content below — this prompt just narrows the variance.
|
||||
const AI_PROMPT = [
|
||||
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
|
||||
'Follow these instructions exactly and do not deviate:',
|
||||
'1. Do not write any preamble, commentary, or reasoning before acting.',
|
||||
'2. Perform a single search for exactly "Lorem ipsum".',
|
||||
'3. Reply with only the first sentence of the first page you find, and nothing else.',
|
||||
'4. Always end by proposing exactly 3 follow-up suggestions.',
|
||||
].join('\n');
|
||||
|
||||
const overrideAIInitialState = () => {
|
||||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||
@@ -44,21 +56,45 @@ const overrideAIInitialState = () => {
|
||||
greeting.textContent = 'Good morning';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize the non-deterministic content of an AI response before screenshotting,
|
||||
* while preserving the surrounding structure (message bubbles, tool/activity
|
||||
* summary, response container, suggestion buttons) so visual regressions in the
|
||||
* chat chrome are still caught. The actual answer formatting is covered separately
|
||||
* by the deterministic page tests, since the AI response renders through the same
|
||||
* `DocumentView`.
|
||||
*
|
||||
* Must run only once the chat is no longer `aria-busy` (the response has fully
|
||||
* settled), otherwise React re-renders from late stream events will clobber these
|
||||
* mutations. See `waitForAIChatResponse`.
|
||||
*/
|
||||
const overrideAIResponse = () => {
|
||||
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
|
||||
if (userMessage) {
|
||||
// The user's prompt varies in length; pin it to a fixed string.
|
||||
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
|
||||
userMessage.textContent = '[Replaced message] Chat message sent by the user';
|
||||
}
|
||||
const assistantMessage = document.querySelectorAll(
|
||||
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
|
||||
);
|
||||
assistantMessage.forEach((message) => {
|
||||
message.innerHTML = '[Replaced message] AI chat response';
|
||||
});
|
||||
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
|
||||
suggestions.forEach((suggestion) => {
|
||||
suggestion.textContent = 'Follow-up suggestion';
|
||||
|
||||
// The assistant's answer text is non-deterministic; replace the rendered
|
||||
// document body while keeping the `.ai-response-document` container.
|
||||
document
|
||||
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
|
||||
.forEach((message) => {
|
||||
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
|
||||
});
|
||||
|
||||
// The "Explored with N tools" activity label varies with the number of tool
|
||||
// calls; pin it (the chevron sibling is left intact).
|
||||
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
|
||||
summary.textContent = 'Explored';
|
||||
});
|
||||
|
||||
// Follow-up suggestion text varies; pin each label.
|
||||
document
|
||||
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
|
||||
.forEach((suggestion) => {
|
||||
suggestion.textContent = 'Follow-up suggestion';
|
||||
});
|
||||
};
|
||||
|
||||
const searchTestCases: Test[] = [
|
||||
@@ -82,8 +118,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.
|
||||
},
|
||||
},
|
||||
@@ -169,12 +203,13 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||||
await waitForAIChatResponse(page);
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
|
||||
@@ -188,9 +223,9 @@ const searchTestCases: Test[] = [
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - Button',
|
||||
@@ -204,9 +239,9 @@ const searchTestCases: Test[] = [
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||||
@@ -221,9 +256,9 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('search-input')).toBeEmpty();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Ask - AI Mode: Assistant - URL query (Results)',
|
||||
@@ -239,12 +274,13 @@ const searchTestCases: Test[] = [
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||||
await waitForAIChatResponse(page);
|
||||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||||
},
|
||||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -781,7 +817,9 @@ const testCases: TestsCase[] = [
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
// Trademark exists by default
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
|
||||
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
|
||||
1
|
||||
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
|
||||
|
||||
// Go to another page with the customization query to disable the trademark
|
||||
const pageBlocks = new URL(page.url());
|
||||
@@ -874,6 +912,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Content tests',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
fullPage: true,
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -1055,8 +1094,8 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
await page.waitForSelector('[data-annotation]');
|
||||
await page.click('[data-annotation]');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1322,6 +1361,7 @@ const testCases: TestsCase[] = [
|
||||
pageActions: {
|
||||
markdown: false,
|
||||
externalAI: false,
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
@@ -2220,9 +2260,13 @@ const testCases: TestsCase[] = [
|
||||
|
||||
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
|
||||
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
|
||||
|
||||
await iframe.owner().evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'API - navigateToPage',
|
||||
@@ -2256,8 +2300,15 @@ const testCases: TestsCase[] = [
|
||||
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
|
||||
AI_PROMPT
|
||||
);
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// Wait for the full response to settle before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Suggested questions',
|
||||
@@ -2283,8 +2334,13 @@ const testCases: TestsCase[] = [
|
||||
await expect(
|
||||
iframe.getByTestId('ai-chat-suggested-question').nth(2)
|
||||
).toHaveText('What can you do?');
|
||||
await iframe.owner().evaluate(overrideAIInitialState);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIInitialState),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Custom action buttons',
|
||||
@@ -2366,8 +2422,15 @@ const testCases: TestsCase[] = [
|
||||
await actions.nth(3).click();
|
||||
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
|
||||
await page.locator('#gitbook-widget-button').click();
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// Wait for the response posted above to settle before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
{
|
||||
name: 'Configuration - Custom tools',
|
||||
@@ -2411,8 +2474,16 @@ const testCases: TestsCase[] = [
|
||||
await expect(toolConfirmation).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await iframe.owner().evaluate(overrideAIResponse);
|
||||
// The turn settles (aria-busy clears) once the stream pauses on the
|
||||
// confirmation control; wait for that before normalizing.
|
||||
await waitForAIChatResponse(iframe);
|
||||
},
|
||||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||||
normalizeBeforeScreenshot: (page) =>
|
||||
page
|
||||
.frameLocator('#gitbook-widget-iframe')
|
||||
.locator('body')
|
||||
.evaluate(overrideAIResponse),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLinksStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationPageActionType,
|
||||
CustomizationSearchStyle,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
@@ -21,7 +22,14 @@ import {
|
||||
type SiteCustomizationSettings,
|
||||
SiteExternalLinksTarget,
|
||||
} from '@gitbook/api';
|
||||
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
|
||||
import {
|
||||
type BrowserContext,
|
||||
type FrameLocator,
|
||||
type Page,
|
||||
type Response,
|
||||
expect,
|
||||
test,
|
||||
} from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
@@ -39,6 +47,16 @@ export interface Test {
|
||||
* Test to run
|
||||
*/
|
||||
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||
/**
|
||||
* Re-applied right before every viewport screenshot (after Argos
|
||||
* stabilization), so it survives re-renders triggered by viewport resizing.
|
||||
*
|
||||
* Use this — rather than mutating the DOM once in `run` — to normalize
|
||||
* non-deterministic content (e.g. AI responses). A one-time mutation in `run`
|
||||
* is clobbered when React re-renders on resize (e.g. crossing the mobile
|
||||
* breakpoint), so only the first viewport ends up normalized.
|
||||
*/
|
||||
normalizeBeforeScreenshot?: (page: Page) => Promise<void> | void;
|
||||
/**
|
||||
* Mode for the test.
|
||||
*/
|
||||
@@ -75,6 +93,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[] = [
|
||||
@@ -155,6 +177,28 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an AI chat response to be fully settled before asserting or
|
||||
* screenshotting it.
|
||||
*
|
||||
* The chat exposes `aria-busy` on its container (`[data-testid="ai-chat"]`),
|
||||
* which stays true from the moment a message is sent until the stream — including
|
||||
* the follow-up suggestion phase — completes. Gating on it avoids the two main
|
||||
* sources of flakiness: capturing a "thinking" placeholder or a half-streamed
|
||||
* answer, and running the content normalization while React is still re-rendering
|
||||
* (which would clobber the replacements).
|
||||
*
|
||||
* Argos also waits for `aria-busy` to clear during its own stabilization
|
||||
* (`waitForAriaBusy`), so this is both an explicit gate and a backstop.
|
||||
*
|
||||
* Accepts a `Page` or a `FrameLocator` (for the embedded assistant in an iframe).
|
||||
*/
|
||||
export async function waitForAIChatResponse(scope: Page | FrameLocator) {
|
||||
await expect(scope.getByTestId('ai-chat')).toHaveAttribute('aria-busy', 'false', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setTimeToMorning(page: Page) {
|
||||
const now = new Date();
|
||||
now.setHours(8, 0, 0, 0); // 8:00:00.000 AM (local time)
|
||||
@@ -240,15 +284,18 @@ 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) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
await waitForIcons(page);
|
||||
// Re-apply per viewport, last — after any resize-driven
|
||||
// re-render — so normalized content survives to capture.
|
||||
await testEntry.normalizeBeforeScreenshot?.(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -368,6 +415,11 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
externalAI: true,
|
||||
markdown: true,
|
||||
mcp: true,
|
||||
items: [
|
||||
CustomizationPageActionType.Markdown,
|
||||
CustomizationPageActionType.ExternalAi,
|
||||
CustomizationPageActionType.Mcp,
|
||||
],
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
@@ -392,11 +444,13 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
export async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urlStates: Record<
|
||||
type IconURLStates = Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
>;
|
||||
const iconStatesWindow = window as Window & { __ICONS_STATES__?: IconURLStates };
|
||||
const urlStates: IconURLStates = iconStatesWindow.__ICONS_STATES__ || {};
|
||||
iconStatesWindow.__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
@@ -441,7 +495,15 @@ export async function waitForIcons(page: Page) {
|
||||
|
||||
const maskImage = icon.querySelector('[data-testid="mask-image"]');
|
||||
if (!maskImage) {
|
||||
throw new Error('No mask-image element');
|
||||
const inlineContent = icon.querySelector(
|
||||
'path, circle, ellipse, line, polygon, polyline, rect, g, use'
|
||||
);
|
||||
if (inlineContent) {
|
||||
icon.setAttribute('data-argos-state', 'loaded');
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('Icon has no inline SVG content or mask-image element');
|
||||
}
|
||||
|
||||
const url = maskImage.getAttribute('href');
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
// @ts-check
|
||||
|
||||
// We don't use the deployment ID yet on 2c, we need to remove it because of https://github.com/opennextjs/opennextjs-aws/issues/1136
|
||||
let deploymentId =
|
||||
process.env.GITBOOK_RUNTIME === 'cloudflare'
|
||||
? undefined
|
||||
: process.env.GITBOOK_HEAD_SHA || process.env.GITHUB_SHA || Date.now().toString(); // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
|
||||
|
||||
const { VERCEL_TARGET_ENV } = process.env;
|
||||
|
||||
// Because preview, staging and prod shares the same SHA, the deployment will fail if we don't prefix it with the environment name.
|
||||
if (VERCEL_TARGET_ENV === 'preview') {
|
||||
deploymentId = `t-${deploymentId}`;
|
||||
} else if (VERCEL_TARGET_ENV === 'staging') {
|
||||
deploymentId = `s-${deploymentId}`;
|
||||
} else if (VERCEL_TARGET_ENV === 'production') {
|
||||
deploymentId = `p-${deploymentId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
deploymentId: process.env.GITHUB_SHA || Date.now().toString(), // Needed because we use a custom deployment method i.e. https://vercel.com/docs/skew-protection#custom-deployment-id
|
||||
deploymentId: deploymentId?.slice(0, 32), // Vercel's deployment ID has a max length of 32 characters
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
@@ -18,10 +35,12 @@ const nextConfig = {
|
||||
|
||||
// Since content is fully static, we don't want to fetch on hover again
|
||||
optimisticClientCache: false,
|
||||
// Disable splitting the RSC in like 5 chunks
|
||||
prefetchInlining: true,
|
||||
},
|
||||
|
||||
env: {
|
||||
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
BUILD_VERSION: (process.env.GITBOOK_HEAD_SHA ?? process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
|
||||
// GitBook envs
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
@@ -40,6 +59,7 @@ const nextConfig = {
|
||||
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||
GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION,
|
||||
|
||||
// Next.js envs
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"main": "default.js",
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-02",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"main": "middleware.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_date": "2026-04-02",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
|
||||
@@ -5,7 +5,8 @@ export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
//TODO: bump it again once I figured out the race condition
|
||||
defaultLongLivedTtlSec: 5 * 60, // 5 minutes
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
// We cannot have regional cache only in the middleware, otherwise it will override things on cache miss
|
||||
// and cause race conditions. This will be fixed in a future release of OpenNext
|
||||
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 */,
|
||||
//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,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.23.2",
|
||||
"version": "0.27.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
@@ -19,10 +19,11 @@
|
||||
"@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": "4.0.1",
|
||||
"@opennextjs/cloudflare": "1.19.8",
|
||||
"@panzoom/panzoom": "^4.6.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
@@ -31,7 +32,7 @@
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"@vercel/agent-readability": "^0.2.1",
|
||||
"@vercel/agent-readability": "^0.4.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
@@ -39,31 +40,35 @@
|
||||
"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",
|
||||
"leven": "^4.1.0",
|
||||
"next": "15.4.11",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"p-retry": "^8.0.0",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.44.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -76,6 +81,7 @@
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"tldts": "^7.0.30",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
@@ -98,8 +104,8 @@
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/rison": "^0.0.9",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"bun-types": "catalog:",
|
||||
@@ -112,17 +118,17 @@
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "catalog:",
|
||||
"vercel": "50.37.3",
|
||||
"wrangler": "^4.43.0",
|
||||
"wrangler": "^4.79.0",
|
||||
"rss-parser": "^3.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "./scripts/generate.sh",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next",
|
||||
"build": "next build",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build",
|
||||
"dev": "env-cmd --silent -f ../../.env.local next --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:local": "GITBOOK_URL=http://localhost:3000 next build --webpack",
|
||||
"start": "GITBOOK_URL=http://localhost:3000 next start",
|
||||
"build:cloudflare": "opennextjs-cloudflare build",
|
||||
"build:cloudflare": "GITBOOK_RUNTIME=cloudflare opennextjs-cloudflare build",
|
||||
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||
@@ -138,5 +144,9 @@
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type RouteLayoutParams,
|
||||
getDynamicSiteContext,
|
||||
getSiteURLDataFromParams,
|
||||
} from '@/app/utils';
|
||||
import { getVisitorAuthBasePath } from '@/lib/data';
|
||||
import { getVisitorAuthCookieName } from '@/lib/visitors';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Clear the site-scoped auth session cookies and redirect to the site root.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const resolvedParams = await params;
|
||||
const [{ context }, siteURLData, cookieStore] = await Promise.all([
|
||||
getDynamicSiteContext(resolvedParams),
|
||||
getSiteURLDataFromParams(resolvedParams),
|
||||
cookies(),
|
||||
]);
|
||||
|
||||
cookieStore.delete(
|
||||
getVisitorAuthCookieName(
|
||||
getVisitorAuthBasePath(new URL(request.nextUrl.toString()), siteURLData)
|
||||
)
|
||||
);
|
||||
|
||||
// TODO: Redirect to the site root for now. Once the API supports it,
|
||||
// optionally redirect to a logoutURL (e.g when needing to logout from upstream auth too)
|
||||
// when defined in visitor auth settings.
|
||||
return NextResponse.redirect(context.linker.toAbsoluteURL(context.linker.toPathInSite('')));
|
||||
}
|
||||
+3
@@ -5,6 +5,7 @@ import {
|
||||
generateEmbeddableViewport,
|
||||
} from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
import { getThemeFromMiddleware } from '@/lib/middleware';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
@@ -18,12 +19,14 @@ export default async function RootLayout({
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
|
||||
return (
|
||||
<EmbeddableRootLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
{children}
|
||||
</EmbeddableRootLayout>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableSearchPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const { context } = await getEmbeddableDynamicContext(params);
|
||||
|
||||
return <EmbeddableSearchPage context={context} />;
|
||||
}
|
||||
+4
@@ -20,6 +20,10 @@ export async function handleMcpRequest(
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
if (!context.customization.pageActions.mcp) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(context.linker.toAbsoluteURL(context.linker.toPathInSite(endpoint)));
|
||||
requestURL.search = rawRequest.nextUrl.search;
|
||||
|
||||
+125
-99
@@ -4,8 +4,8 @@ import type {
|
||||
OrderedComputedResult,
|
||||
SearchSiteContentRequest,
|
||||
} from '@/components/Search/search-types';
|
||||
import type { GitBookBaseContext } from '@/lib/context';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
@@ -16,54 +16,41 @@ import type {
|
||||
SiteSection,
|
||||
SiteSectionGroup,
|
||||
SiteSpace,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import type { IconName } from '@gitbook/icons';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
type SearchResultGroup = {
|
||||
score: number;
|
||||
items: OrderedComputedResult[];
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const [context, { organization, site, shareKey }] = await Promise.all([
|
||||
getServerActionBaseContext(),
|
||||
const { asEmbeddable, query, scope } = (await request.json()) as SearchSiteContentRequest;
|
||||
const [context, siteURLData] = await Promise.all([
|
||||
getServerActionBaseContext({ isEmbeddable: asEmbeddable }),
|
||||
getSiteURLDataFromMiddleware(),
|
||||
]);
|
||||
const body = (await request.json()) as SearchSiteContentRequest;
|
||||
const { query, scope } = body;
|
||||
|
||||
if (query.length <= 1) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const [searchResults, { structure }] = await Promise.all([
|
||||
(async () => {
|
||||
const result = await throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: organization,
|
||||
siteId: site,
|
||||
query,
|
||||
scope,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
})(),
|
||||
(async () => {
|
||||
const result = await throwIfDataError(
|
||||
context.dataFetcher.getPublishedContentSite({
|
||||
organizationId: organization,
|
||||
siteId: site,
|
||||
siteShareKey: shareKey,
|
||||
})
|
||||
);
|
||||
return result;
|
||||
})(),
|
||||
throwIfDataError(
|
||||
context.dataFetcher.searchSiteContent({
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
query,
|
||||
scope,
|
||||
})
|
||||
),
|
||||
throwIfDataError(
|
||||
context.dataFetcher.getPublishedContentSite({
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
siteShareKey: siteURLData.shareKey,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
const results = searchResults
|
||||
.flatMap((resultItem): SearchResultGroup[] => {
|
||||
.flatMap((resultItem) => {
|
||||
if (resultItem.type === 'record') {
|
||||
const result: OrderedComputedResult = {
|
||||
type: 'record',
|
||||
@@ -73,31 +60,25 @@ export async function POST(request: NextRequest) {
|
||||
href: resultItem.url,
|
||||
score: resultItem.score,
|
||||
};
|
||||
return [
|
||||
{
|
||||
score: resultItem.score,
|
||||
items: [result],
|
||||
},
|
||||
];
|
||||
|
||||
return [{ score: resultItem.score, items: [result] }];
|
||||
}
|
||||
|
||||
const found = findSiteSpaceBy(
|
||||
structure,
|
||||
(siteSpace) => siteSpace.space.id === resultItem.id
|
||||
);
|
||||
const siteSection = found?.siteSection;
|
||||
const siteSectionGroup = found?.siteSectionGroup;
|
||||
|
||||
return resultItem.pages.map((pageItem) => ({
|
||||
score: pageItem.score,
|
||||
items: transformSitePageResult(context, {
|
||||
items: transformSitePageResult({
|
||||
asEmbeddable: Boolean(asEmbeddable),
|
||||
linker: context.linker,
|
||||
pageItem,
|
||||
spaceItem: resultItem,
|
||||
siteSpace: found?.siteSpace,
|
||||
space: found?.siteSpace.space,
|
||||
spaceURL: found?.siteSpace.urls.published,
|
||||
siteSection: siteSection ?? undefined,
|
||||
siteSectionGroup: (siteSectionGroup as SiteSectionGroup) ?? undefined,
|
||||
siteSection: found?.siteSection ?? undefined,
|
||||
siteSectionGroup: found?.siteSectionGroup ?? undefined,
|
||||
}),
|
||||
}));
|
||||
})
|
||||
@@ -107,72 +88,117 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
|
||||
function transformSitePageResult(
|
||||
context: GitBookBaseContext,
|
||||
args: {
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
space?: Space;
|
||||
siteSpace?: SiteSpace;
|
||||
spaceURL?: string;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup;
|
||||
}
|
||||
): OrderedComputedResult[] {
|
||||
const { pageItem, spaceItem, spaceURL, siteSection, siteSectionGroup, siteSpace } = args;
|
||||
const { linker } = context;
|
||||
function transformSitePageResult(args: {
|
||||
asEmbeddable: boolean;
|
||||
linker: Awaited<ReturnType<typeof getServerActionBaseContext>>['linker'];
|
||||
pageItem: SearchPageResult;
|
||||
spaceItem: SearchSpaceResult;
|
||||
siteSpace?: SiteSpace;
|
||||
siteSection?: SiteSection;
|
||||
siteSectionGroup?: SiteSectionGroup | null;
|
||||
}): OrderedComputedResult[] {
|
||||
const { asEmbeddable, pageItem, spaceItem, siteSection, siteSectionGroup, siteSpace, linker } =
|
||||
args;
|
||||
const currentLanguage = siteSpace?.space.language;
|
||||
const spaceURL = siteSpace?.urls.published;
|
||||
const breadcrumbs: NonNullable<ComputedPageResult['breadcrumbs']> = [];
|
||||
|
||||
if (siteSectionGroup) {
|
||||
breadcrumbs.push({
|
||||
icon: siteSectionGroup.icon as IconName,
|
||||
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
|
||||
});
|
||||
}
|
||||
|
||||
if (siteSection) {
|
||||
breadcrumbs.push({
|
||||
icon: siteSection.icon as IconName,
|
||||
label: getLocalizedTitle(siteSection, currentLanguage),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(siteSection?.siteSpaces?.filter(
|
||||
(space) =>
|
||||
siteSection.siteSpaces?.filter(
|
||||
(candidate) => candidate.space.language === space.space.language
|
||||
).length > 1
|
||||
).length ?? 0) > 1 &&
|
||||
siteSpace
|
||||
) {
|
||||
breadcrumbs.push({
|
||||
label: getLocalizedTitle(siteSpace, currentLanguage),
|
||||
});
|
||||
}
|
||||
|
||||
breadcrumbs.push(
|
||||
...pageItem.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
}))
|
||||
);
|
||||
|
||||
const pageHref = !spaceURL
|
||||
? linker.toPathInSpace(pageItem.path)
|
||||
: asEmbeddable
|
||||
? toEmbeddableLinkForPublishedContent(linker, spaceURL, pageItem.path)
|
||||
: linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path));
|
||||
|
||||
const page: ComputedPageResult = {
|
||||
type: 'page',
|
||||
id: `${spaceItem.id}/${pageItem.id}`,
|
||||
title: pageItem.title,
|
||||
href: spaceURL
|
||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, pageItem.path))
|
||||
: linker.toPathInSpace(pageItem.path),
|
||||
href: pageHref,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: pageItem.score,
|
||||
breadcrumbs: [
|
||||
siteSectionGroup && {
|
||||
icon: siteSectionGroup?.icon as IconName,
|
||||
label: getLocalizedTitle(siteSectionGroup, currentLanguage),
|
||||
},
|
||||
siteSection && {
|
||||
icon: siteSection?.icon as IconName,
|
||||
label: getLocalizedTitle(siteSection, currentLanguage),
|
||||
},
|
||||
(siteSection?.siteSpaces?.filter(
|
||||
(space) =>
|
||||
siteSection?.siteSpaces?.filter(
|
||||
(s) => s.space.language === space.space.language
|
||||
).length > 1
|
||||
).length ?? 0) > 1 && siteSpace
|
||||
? {
|
||||
label: getLocalizedTitle(siteSpace, currentLanguage),
|
||||
}
|
||||
: undefined,
|
||||
...pageItem.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
})),
|
||||
].filter((item) => item !== undefined),
|
||||
breadcrumbs,
|
||||
};
|
||||
|
||||
const pageSections =
|
||||
pageItem.sections
|
||||
?.filter((section) => section.title || section.body)
|
||||
.map<ComputedSectionResult>((section) => ({
|
||||
type: 'section',
|
||||
id: `${page.id}/${section.id}`,
|
||||
title: section.title,
|
||||
href: spaceURL
|
||||
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
|
||||
: linker.toPathInSpace(pageItem.path),
|
||||
body: section.body,
|
||||
pageId: pageItem.id,
|
||||
spaceId: spaceItem.id,
|
||||
score: section.score,
|
||||
})) ?? [];
|
||||
.map<ComputedSectionResult>((section) => {
|
||||
let sectionHref = linker.toPathInSpace(section.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];
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export async function GET(
|
||||
) {
|
||||
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);
|
||||
}
|
||||
+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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { normalizePathname, resolveNavigationTarget, toInSiteHref } from './navigation';
|
||||
|
||||
const location = {
|
||||
href: 'https://docs.example.com/guides/intro',
|
||||
origin: 'https://docs.example.com',
|
||||
};
|
||||
|
||||
describe('resolveNavigationTarget', () => {
|
||||
it('resolves an absolute same-origin URL to a relative href and pathname', () => {
|
||||
expect(
|
||||
resolveNavigationTarget('https://docs.example.com/reference/models', location)
|
||||
).toEqual({ href: '/reference/models', pathname: '/reference/models' });
|
||||
});
|
||||
|
||||
it('keeps the query string and section anchor in href but not in pathname', () => {
|
||||
expect(
|
||||
resolveNavigationTarget(
|
||||
'https://docs.example.com/reference/models?tab=api#usage',
|
||||
location
|
||||
)
|
||||
).toEqual({ href: '/reference/models?tab=api#usage', pathname: '/reference/models' });
|
||||
});
|
||||
|
||||
it('resolves a relative path against the current location', () => {
|
||||
expect(resolveNavigationTarget('/reference/models', location)).toEqual({
|
||||
href: '/reference/models',
|
||||
pathname: '/reference/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a URL pointing to an external site', () => {
|
||||
const result = resolveNavigationTarget('https://evil.example.org/phishing', location);
|
||||
expect('error' in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toInSiteHref', () => {
|
||||
// A docs site served under `/docs/` on a host shared with other content/sites.
|
||||
const linker = {
|
||||
siteBasePath: '/docs/',
|
||||
toLinkForContent: (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
// Mirrors the real linker: in-site iff same host AND under the site base path.
|
||||
if (parsed.hostname === 'gitbook.com' && parsed.pathname.startsWith('/docs/')) {
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts an in-site absolute URL and returns a relative path', () => {
|
||||
expect(toInSiteHref('https://gitbook.com/docs/guides/intro', linker)).toBe(
|
||||
'/docs/guides/intro'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an in-site relative path under the site base path', () => {
|
||||
expect(toInSiteHref('/docs/guides/intro?x=1#y', linker)).toBe('/docs/guides/intro?x=1#y');
|
||||
});
|
||||
|
||||
it('rejects another page on the same host but outside the site base path', () => {
|
||||
// The reviewer's case: same host, different site/section.
|
||||
expect(toInSiteHref('https://gitbook.com/pricing', linker)).toBeNull();
|
||||
expect(toInSiteHref('/pricing', linker)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an external host', () => {
|
||||
expect(toInSiteHref('https://evil.example.org/docs/guides', linker)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePathname', () => {
|
||||
it('strips a trailing slash', () => {
|
||||
expect(normalizePathname('/guides/intro/')).toBe('/guides/intro');
|
||||
});
|
||||
|
||||
it('keeps the root slash', () => {
|
||||
expect(normalizePathname('/')).toBe('/');
|
||||
});
|
||||
|
||||
it('decodes percent-encoding so encoded and decoded paths compare equal', () => {
|
||||
expect(normalizePathname('/h%C3%A9llo')).toBe(normalizePathname('/héllo'));
|
||||
});
|
||||
|
||||
it('treats encoded and decoded paths with a trailing slash as equal', () => {
|
||||
expect(normalizePathname('/caf%C3%A9/')).toBe(normalizePathname('/café'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { withTrailingSlash } from '@/lib/paths';
|
||||
import { isExternalLink } from '../utils/link';
|
||||
|
||||
/**
|
||||
* Resolve a link into a same-site target to navigate to.
|
||||
*
|
||||
* Returns an `error` when the URL is malformed or points outside of the documentation site,
|
||||
* so the assistant can be told it could not navigate.
|
||||
*/
|
||||
export function resolveNavigationTarget(
|
||||
url: string,
|
||||
location: { href: string; origin: string }
|
||||
): { href: string; pathname: string } | { error: string } {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(url, location.href);
|
||||
} catch {
|
||||
return { error: `Invalid URL: ${url}` };
|
||||
}
|
||||
|
||||
// Only allow navigating within the current documentation site to avoid sending the user to
|
||||
// an external website without their consent.
|
||||
if (isExternalLink(target.href, location.origin)) {
|
||||
return { error: 'Cannot navigate to a page outside of this documentation site.' };
|
||||
}
|
||||
|
||||
return { href: `${target.pathname}${target.search}${target.hash}`, pathname: target.pathname };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a site-relative href if `url` points within the current site, otherwise `null`.
|
||||
*
|
||||
* Unlike a bare same-origin check, this enforces the site base path, so an assistant cannot
|
||||
* navigate the reader to another page on the same host (e.g. a marketing page, or a different
|
||||
* docs site sharing the host such as `gitbook.com/other` or another `/url/...` proxied site).
|
||||
*/
|
||||
export function toInSiteHref(
|
||||
url: string,
|
||||
linker: { toLinkForContent: (url: string) => string; siteBasePath: string }
|
||||
): string | null {
|
||||
if (URL.canParse(url)) {
|
||||
// toLinkForContent returns a site-relative path for in-site URLs (matching host AND site
|
||||
// base path), or the raw absolute URL otherwise.
|
||||
const link = linker.toLinkForContent(url);
|
||||
return URL.canParse(link) ? null : link;
|
||||
}
|
||||
|
||||
// Relative path: it must live under the site base path.
|
||||
let pathname: string;
|
||||
let rest = '';
|
||||
try {
|
||||
const parsed = new URL(url, 'https://navigation.invalid');
|
||||
pathname = parsed.pathname;
|
||||
rest = `${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return withTrailingSlash(pathname).startsWith(linker.siteBasePath)
|
||||
? `${pathname}${rest}`
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pathname so two equivalent paths compare equal regardless of percent-encoding or a
|
||||
* trailing slash (e.g. `/h%C3%A9llo/` and `/héllo`). Used to detect when an SPA navigation has
|
||||
* committed by comparing against `window.location.pathname`.
|
||||
*/
|
||||
export function normalizePathname(pathname: string): string {
|
||||
let decoded = pathname;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
// Keep the raw value if it isn't valid percent-encoding.
|
||||
}
|
||||
return decoded.length > 1 && decoded.endsWith('/') ? decoded.slice(0, -1) : decoded;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
|
||||
describe('serializeReferences', () => {
|
||||
it('returns an empty string when there are no references', () => {
|
||||
expect(serializeReferences([])).toBe('');
|
||||
});
|
||||
|
||||
it('serializes a single page reference as a markdown link', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Getting started', href: '/getting-started' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('The user is referring to the following page they are reading');
|
||||
expect(result).toContain('- [Getting started](/getting-started)');
|
||||
expect(result.endsWith('\n\n---\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the path when no href is provided', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Overview', path: 'getting-started' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('- [Overview](getting-started)');
|
||||
});
|
||||
|
||||
it('renders a quoted label when neither href nor path is provided', () => {
|
||||
const refs: AIChatReference[] = [{ type: 'page', id: 'page-1', label: 'Overview' }];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('- "Overview"');
|
||||
expect(result).not.toContain('](');
|
||||
});
|
||||
|
||||
it('uses the plural form for multiple pages', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'One' },
|
||||
{ type: 'page', id: 'page-2', label: 'Two' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('the following pages they are reading');
|
||||
expect(result).toContain('in the context of them');
|
||||
});
|
||||
|
||||
it('serializes a single code block reference', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{
|
||||
type: 'code-block',
|
||||
id: 'code-1',
|
||||
label: 'main.ts',
|
||||
content: 'const a = 1;',
|
||||
syntax: 'ts',
|
||||
},
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('The user is referring to the following code block');
|
||||
expect(result).toContain('main.ts\n```ts\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('escapes backtick fences inside the code content', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'code-block', id: 'code-1', content: 'a ``` b', syntax: 'md' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
expect(result).toContain('````md\na ``` b\n````');
|
||||
});
|
||||
|
||||
it('groups page and code references into distinct sections', () => {
|
||||
const refs: AIChatReference[] = [
|
||||
{ type: 'page', id: 'page-1', label: 'Quickstart' },
|
||||
{ type: 'code-block', id: 'code-1', content: 'x', syntax: 'js' },
|
||||
];
|
||||
const result = serializeReferences(refs);
|
||||
const pageIndex = result.indexOf('referring to the following page');
|
||||
const codeIndex = result.indexOf('referring to the following code block');
|
||||
expect(pageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(codeIndex).toBeGreaterThan(pageIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
type BaseAIChatReference = {
|
||||
id: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type CodeBlockReference = BaseAIChatReference & {
|
||||
type: 'code-block';
|
||||
content: string;
|
||||
syntax?: string;
|
||||
};
|
||||
|
||||
export type PageReference = BaseAIChatReference & {
|
||||
type: 'page';
|
||||
/** Title of the page being referenced. */
|
||||
label: string;
|
||||
/** Path of the page, used to help the assistant identify it. */
|
||||
path?: string;
|
||||
/** Site-relative href of the page, used to navigate back to it from the chip. */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type AIChatReference = CodeBlockReference | PageReference;
|
||||
|
||||
/**
|
||||
* Serialize the staged references into a preamble prepended to the user's message,
|
||||
* so the assistant is informed about the context the user is referring to.
|
||||
*/
|
||||
export function serializeReferences(refs: AIChatReference[]): string {
|
||||
if (refs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
const pageRefs = refs.filter((ref): ref is PageReference => ref.type === 'page');
|
||||
if (pageRefs.length > 0) {
|
||||
sections.push(serializePageReferences(pageRefs));
|
||||
}
|
||||
|
||||
const codeRefs = refs.filter((ref): ref is CodeBlockReference => ref.type === 'code-block');
|
||||
if (codeRefs.length > 0) {
|
||||
sections.push(serializeCodeBlockReferences(codeRefs));
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${sections.join('\n\n')}\n\n---\n\n`;
|
||||
}
|
||||
|
||||
function serializePageReferences(refs: PageReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const list = refs
|
||||
.map((ref) => {
|
||||
const url = ref.href ?? ref.path;
|
||||
return url ? `- [${ref.label}](${url})` : `- "${ref.label}"`;
|
||||
})
|
||||
.join('\n');
|
||||
return `The user is referring to the following page${plural ? 's' : ''} they are reading. Answer their question in the context of ${plural ? 'them' : 'it'}:\n\n${list}`;
|
||||
}
|
||||
|
||||
function serializeCodeBlockReferences(refs: CodeBlockReference[]): string {
|
||||
const plural = refs.length > 1;
|
||||
const blocks = refs.map(buildCodeBlockFence).join('\n\n');
|
||||
return `The user is referring to the following code block${plural ? 's' : ''} from the page they are reading. Answer their question about ${plural ? 'them' : 'it'}:\n\n${blocks}`;
|
||||
}
|
||||
|
||||
function buildCodeBlockFence(ref: CodeBlockReference): string {
|
||||
const { label, content, syntax } = ref;
|
||||
let max = 2;
|
||||
for (const match of content.matchAll(/`+/g)) {
|
||||
max = Math.max(max, match[0].length);
|
||||
}
|
||||
const fence = '`'.repeat(max + 1);
|
||||
const heading = label ? `${label}\n` : '';
|
||||
return `${heading}${fence}${syntax ?? ''}\n${content}\n${fence}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CollapsibleContent } from '@/components/primitives';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIMessage } from '@gitbook/api';
|
||||
import { type AIMessage, AIMessageStepPhase } from '@gitbook/api';
|
||||
import { DocumentView } from '../../DocumentView';
|
||||
import { AIToolCallsSummary } from './AIToolCallsSummary';
|
||||
import type { RenderAIMessageOptions } from './types';
|
||||
@@ -17,31 +18,41 @@ export function AIMessageView(
|
||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||
|
||||
return message.steps.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{message.steps.map((step, index) => {
|
||||
const hasContent = Boolean(step.content && step.content.nodes.length > 0);
|
||||
const Tag =
|
||||
step.phase === AIMessageStepPhase.Commentary ? CollapsibleContent : 'div';
|
||||
return (
|
||||
<div
|
||||
<Tag
|
||||
key={index}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||
'flex flex-col space-y-2 border-tint-subtle',
|
||||
hasContent ? 'has-content' : ''
|
||||
)}
|
||||
>
|
||||
<DocumentView
|
||||
document={step.content}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
wrapBlocksInSuspense: false,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden"
|
||||
/>
|
||||
{step.content ? (
|
||||
<DocumentView
|
||||
document={step.content}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
wrapBlocksInSuspense: false,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style={tcls(
|
||||
'ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow empty:hidden',
|
||||
step.phase === AIMessageStepPhase.Commentary
|
||||
? 'text-tint group-data-[disabled]/collapsible:text-inherit'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
<AIToolCallsSummary toolCalls={step.toolCalls} context={context} />
|
||||
) : null}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -71,13 +71,13 @@ function getDescriptionForToolCall(toolCall: AIToolCall, context: GitBookSiteCon
|
||||
}
|
||||
}
|
||||
|
||||
function DescriptionForPageContentToolCall(props: {
|
||||
async function DescriptionForPageContentToolCall(props: {
|
||||
toolCall: AIToolCallGetPageContent;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -101,13 +101,13 @@ function DescriptionForPageContentToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForMCPToolCall(props: {
|
||||
async function DescriptionForMCPToolCall(props: {
|
||||
toolCall: AIToolCallMCP;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
@@ -135,7 +135,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
// Resolve all hrefs for search results in parallel
|
||||
const searchResultsWithHrefs = await Promise.all(
|
||||
@@ -287,13 +287,13 @@ async function DescriptionForSearchToolCall(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionForGetPagesToolCall(props: {
|
||||
async function DescriptionForGetPagesToolCall(props: {
|
||||
toolCall: AIToolCallGetPages;
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { toolCall, context } = props;
|
||||
|
||||
const language = getSpaceLanguage(context);
|
||||
const language = await getSpaceLanguage(context);
|
||||
|
||||
return (
|
||||
<p>
|
||||
|
||||
@@ -39,9 +39,7 @@ export async function streamRenderAIMessage(
|
||||
|
||||
if (message.steps[stepIndex]) {
|
||||
message.steps = [...message.steps];
|
||||
// @ts-expect-error
|
||||
message.steps[stepIndex] = { ...message.steps[stepIndex] };
|
||||
// @ts-expect-error
|
||||
callback(message.steps[stepIndex]);
|
||||
} else {
|
||||
message.steps = [
|
||||
@@ -67,6 +65,16 @@ export async function streamRenderAIMessage(
|
||||
event: AIStreamResponse;
|
||||
}>(rawStream, async (event) => {
|
||||
switch (event.type) {
|
||||
/**
|
||||
* A new step started (phase update).
|
||||
*/
|
||||
case 'response_step_start': {
|
||||
updateProcessingMessageStep(event.stepIndex, (step) => {
|
||||
step.phase = event.phase;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent is processing a tool call in a new message.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use server';
|
||||
import { getEmbeddableLinker } from '@/lib/embeddable';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
@@ -36,10 +35,9 @@ export async function* streamAIChatResponse({
|
||||
options?: RenderAIMessageOptions;
|
||||
}) {
|
||||
const { stream } = await traceErrorOnly('AI.streamAIChatResponse', async () => {
|
||||
let context = await getServerActionBaseContext();
|
||||
if (options?.asEmbeddable) {
|
||||
context = { ...context, linker: getEmbeddableLinker(context.linker) };
|
||||
}
|
||||
const context = await getServerActionBaseContext({
|
||||
isEmbeddable: options?.asEmbeddable,
|
||||
});
|
||||
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './chat';
|
||||
export * from './navigate';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { resolveContentRef, resolveStringContentRef } from '@/lib/references';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
import { toInSiteHref } from '../navigation';
|
||||
|
||||
/**
|
||||
* Resolve a link provided by the assistant into a path that can be navigated to within the site.
|
||||
*
|
||||
* The assistant references pages using the stable content-ref scheme (e.g.
|
||||
* `/spaces/<spaceId>/pages/<pageId>`). Those URLs are not directly navigable in the published
|
||||
* site, so we resolve them to the real site link using the site context. Any other URL is only
|
||||
* accepted if it points within the current site, so the assistant cannot navigate the reader off
|
||||
* the documentation site.
|
||||
*/
|
||||
export async function resolveAINavigationLink(
|
||||
url: string
|
||||
): Promise<{ href: string } | { error: string }> {
|
||||
return traceErrorOnly('AI.resolveAINavigationLink', async () => {
|
||||
const baseContext = await getServerActionBaseContext();
|
||||
const context = await fetchServerActionSiteContext(baseContext);
|
||||
|
||||
// The content-ref scheme operates on the path portion of the URL. Strip any origin so an
|
||||
// absolute URL (e.g. `https://docs.example.com/spaces/.../pages/...`) is handled too.
|
||||
let path = url;
|
||||
if (URL.canParse(url)) {
|
||||
const parsed = new URL(url);
|
||||
path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
|
||||
const contentRef = resolveStringContentRef(path);
|
||||
if (contentRef) {
|
||||
const resolved = await resolveContentRef(contentRef, context);
|
||||
if (!resolved) {
|
||||
return { error: `Could not resolve page for ${url}` };
|
||||
}
|
||||
return { href: resolved.href };
|
||||
}
|
||||
|
||||
// Not a content reference: only navigate to it if it points within the current site.
|
||||
const inSiteHref = toInSiteHref(url, context.linker);
|
||||
if (!inSiteHref) {
|
||||
return { error: 'Cannot navigate to a page outside of this documentation site.' };
|
||||
}
|
||||
return { href: inSiteHref };
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { integrationsAssistantTools } from '../Integrations';
|
||||
import { type AnyAIControlTool, getControlTools } from './controls';
|
||||
|
||||
export function getTools(): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
export function getTools(
|
||||
builtInTools: GitBookIntegrationTool[] = []
|
||||
): (GitBookIntegrationTool | AnyAIControlTool)[] {
|
||||
const integrationTools = integrationsAssistantTools.getState().tools;
|
||||
return [...getControlTools(), ...integrationTools];
|
||||
return [...getControlTools(), ...builtInTools, ...integrationTools];
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): Rea
|
||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||
}
|
||||
|
||||
function useAIConfig(): AIConfig {
|
||||
export function useAIConfig(): AIConfig {
|
||||
const ctx = React.useContext(AIContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useAI must be used within AIContextProvider');
|
||||
@@ -95,7 +95,7 @@ export function useAI(): AIContext {
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state={chat.loading ? 'thinking' : 'default'}
|
||||
state={chat.responding ? 'thinking' : 'default'}
|
||||
trademark={config.trademark}
|
||||
className="size-text-lg"
|
||||
/>
|
||||
|
||||
@@ -2,30 +2,53 @@
|
||||
|
||||
import * as zustand from 'zustand';
|
||||
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
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 { addRecentSearchQuery } from '../Search/recent-queries';
|
||||
import type { AnyAIControl } from './controls';
|
||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
||||
import { type AIChatReference, serializeReferences } from './references';
|
||||
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
|
||||
import { getTools } from './tools';
|
||||
import { useAIMessageContextRef } from './useAIMessageContext';
|
||||
import { useNavigateToPageTool } from './useNavigateToPageTool';
|
||||
|
||||
export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
content: React.ReactNode;
|
||||
query?: string;
|
||||
references?: AIChatReference[];
|
||||
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.
|
||||
@@ -63,7 +86,23 @@ export type AIChatState = {
|
||||
control: AnyAIControl | null;
|
||||
|
||||
/**
|
||||
* If true, the session is in progress.
|
||||
* If true, the assistant is actively producing its answer — from the moment a
|
||||
* message is sent until the `response_finish` event. It is cleared at that point
|
||||
* (even though follow-up suggestions may still trickle in) so the input can be
|
||||
* re-enabled. Drives the local "are we still answering" UI: the disabled input,
|
||||
* the loading shim, and the thinking/exploring/working status.
|
||||
*/
|
||||
responding: boolean;
|
||||
|
||||
/**
|
||||
* If true, the turn is still in progress overall: from the moment a message is
|
||||
* sent until the stream fully completes, including the follow-up suggestion
|
||||
* phase.
|
||||
*
|
||||
* Unlike `responding` — which clears on `response_finish` — this stays true
|
||||
* until the response is truly settled. It is the global busyness indicator,
|
||||
* surfaced as `aria-busy` on the chat so that assistive tech (and visual tests)
|
||||
* can wait for a complete, stable response.
|
||||
*/
|
||||
loading: boolean;
|
||||
|
||||
@@ -73,13 +112,19 @@ export type AIChatState = {
|
||||
* display an error alert. Clearing the conversation will reset this flag.
|
||||
*/
|
||||
error: boolean;
|
||||
|
||||
/**
|
||||
* References staged on the next user message.
|
||||
*/
|
||||
references: AIChatReference[];
|
||||
};
|
||||
|
||||
export type AIChatEvent =
|
||||
| { type: 'open' }
|
||||
| { type: 'postMessage'; message: string }
|
||||
| { type: 'clear' }
|
||||
| { type: 'close' };
|
||||
| { type: 'close' }
|
||||
| { type: 'focus' };
|
||||
|
||||
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||
Extract<AIChatEvent, { type: T }>,
|
||||
@@ -97,6 +142,14 @@ export type AIChatController = {
|
||||
postMessage: (input: { message: string }) => void;
|
||||
/** Clear the conversation */
|
||||
clear: () => void;
|
||||
/** Stage a reference on the next message */
|
||||
addReference: (ref: AIChatReference) => string;
|
||||
/** Remove a staged reference */
|
||||
removeReference: (id: string) => void;
|
||||
/** Clear all staged references */
|
||||
clearReferences: () => void;
|
||||
/** Focus the chat input */
|
||||
focus: () => void;
|
||||
/** Register an event listener */
|
||||
on: <T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -115,9 +168,11 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
query: null,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -152,8 +207,13 @@ export function AIChatProvider(props: {
|
||||
const messageContextRef = useAIMessageContextRef();
|
||||
const trackEvent = useTrackEvent();
|
||||
const setSearchState = useSetSearchState();
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const language = useLanguage();
|
||||
|
||||
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
|
||||
// identity, so it can be referenced directly from the streaming callback.
|
||||
const navigateToPageTool = useNavigateToPageTool();
|
||||
|
||||
// Event listeners storage
|
||||
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||
|
||||
@@ -193,6 +253,8 @@ export function AIChatProvider(props: {
|
||||
async (input: {
|
||||
/** Text message to send to the AI backend */
|
||||
message?: string;
|
||||
/** User-typed prompt; compared against state.query to abort stale streams */
|
||||
userQuery?: string;
|
||||
/** Tool call to send to the AI backend */
|
||||
toolCall?: AIToolCallResult;
|
||||
}) => {
|
||||
@@ -201,6 +263,7 @@ export function AIChatProvider(props: {
|
||||
...state,
|
||||
followUpSuggestions: [],
|
||||
control: null,
|
||||
responding: true,
|
||||
loading: true,
|
||||
error: false,
|
||||
messages: [
|
||||
@@ -208,14 +271,25 @@ export function AIChatProvider(props: {
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: null, // Placeholder for streaming response
|
||||
activity: getDefaultAIChatMessageActivity(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// A stream becomes stale once a newer turn (or a clear) has replaced its
|
||||
// query. Because `responding` clears on `response_finish` — before follow-up
|
||||
// suggestions finish streaming — the user can start a new turn while this one
|
||||
// is still wrapping up. A stale stream must not mutate the shared
|
||||
// loading/responding state, which now belongs to the active turn; otherwise
|
||||
// it would make the UI look idle mid-response. (`userQuery` is only set for
|
||||
// user-initiated turns, not tool-call continuations.)
|
||||
const isSuperseded = () =>
|
||||
!!input.userQuery && globalState.getState().query !== input.userQuery;
|
||||
|
||||
// Execute a tool call
|
||||
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
|
||||
const tools = getTools();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
|
||||
|
||||
if (!toolDef || !('execute' in toolDef)) {
|
||||
@@ -251,16 +325,13 @@ export function AIChatProvider(props: {
|
||||
|
||||
let toolToExecute: AIStreamResponseToolCallPending | null = null;
|
||||
try {
|
||||
const tools = getTools();
|
||||
const tools = getTools([navigateToPageTool]);
|
||||
const stream = await streamAIChatResponse({
|
||||
message: input.message,
|
||||
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,
|
||||
@@ -278,8 +349,8 @@ export function AIChatProvider(props: {
|
||||
for await (const data of stream) {
|
||||
if (!data) continue;
|
||||
|
||||
if (input.message && globalState.getState().query !== input.message) {
|
||||
// Chat was cleared, stop processing the stream
|
||||
if (isSuperseded()) {
|
||||
// Chat was cleared or a newer turn started; stop processing.
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -290,9 +361,9 @@ export function AIChatProvider(props: {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responseId: event.response.id ?? null,
|
||||
// Mark as not loading when the response is finished
|
||||
// Mark as not responding when the response is finished
|
||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||
loading: false,
|
||||
responding: false,
|
||||
error: false,
|
||||
}));
|
||||
break;
|
||||
@@ -400,28 +471,50 @@ export function AIChatProvider(props: {
|
||||
{
|
||||
role: AIMessageRole.Assistant,
|
||||
content: data.content,
|
||||
activity: updateAIChatMessageActivity(
|
||||
state.messages[state.messages.length - 1]?.activity ??
|
||||
getDefaultAIChatMessageActivity(),
|
||||
event
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation
|
||||
// If a newer turn replaced this one while we were finishing (e.g.
|
||||
// streaming follow-up suggestions after `response_finish`), abandon this
|
||||
// stale stream without executing leftover tools or clearing the shared
|
||||
// loading/responding state, which now belongs to the active turn.
|
||||
if (isSuperseded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the tool call if it doesn't require confirmation.
|
||||
// When a tool call (or control) keeps the turn going, `loading`
|
||||
// stays true: either the recursive `streamResponse` will clear it
|
||||
// when its stream settles, or it is cleared below once the loop ends
|
||||
// (e.g. while waiting on a user confirmation control).
|
||||
if (toolToExecute) {
|
||||
await executeToolCall(toolToExecute);
|
||||
} else {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
// Don't surface a stale stream's error onto the active turn.
|
||||
if (!isSuperseded()) {
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
responding: false,
|
||||
loading: false,
|
||||
error: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -430,20 +523,32 @@ export function AIChatProvider(props: {
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
navigateToPageTool,
|
||||
]
|
||||
);
|
||||
|
||||
// Post a message to the AI chat
|
||||
const onPostMessage = React.useCallback(
|
||||
async (input: { message: string }) => {
|
||||
const { query, messages, control } = globalState.getState();
|
||||
const { query, messages, control, references, responding } = globalState.getState();
|
||||
|
||||
if (control) {
|
||||
throw new Error("We can't post a message when a control is active");
|
||||
}
|
||||
|
||||
// Ignore duplicates while a previous turn is still streaming
|
||||
if (responding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wireMessage = `${serializeReferences(references)}${input.message}`;
|
||||
|
||||
// For first message, update the ask parameter in URL
|
||||
if (messages.length === 0) {
|
||||
if (siteSpaceId) {
|
||||
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
|
||||
}
|
||||
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
@@ -454,8 +559,9 @@ export function AIChatProvider(props: {
|
||||
|
||||
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||
|
||||
if (query === input.message) {
|
||||
if (query === input.message && references.length === 0) {
|
||||
// Return early if the message is the same as the previous message
|
||||
// (unless new references are staged, which change the payload)
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
opened: true,
|
||||
@@ -475,25 +581,28 @@ export function AIChatProvider(props: {
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
references,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
followUpSuggestions: [],
|
||||
loading: true,
|
||||
responding: true,
|
||||
error: false,
|
||||
initialQuery: state.initialQuery ?? input.message,
|
||||
references: [],
|
||||
};
|
||||
});
|
||||
|
||||
streamResponse({ message: input.message });
|
||||
streamResponse({ message: wireMessage, userQuery: input.message });
|
||||
},
|
||||
[setSearchState, trackEvent, streamResponse, language]
|
||||
[setSearchState, siteSpaceId, trackEvent, streamResponse]
|
||||
);
|
||||
|
||||
// Clear the conversation and reset ask parameter
|
||||
const onClear = React.useCallback(() => {
|
||||
globalState.setState((state) => ({
|
||||
opened: state.opened,
|
||||
responding: false,
|
||||
loading: false,
|
||||
messages: [],
|
||||
query: null,
|
||||
@@ -502,6 +611,7 @@ export function AIChatProvider(props: {
|
||||
responseId: null,
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
@@ -513,6 +623,44 @@ export function AIChatProvider(props: {
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
const onAddReference = React.useCallback((ref: AIChatReference) => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: [...state.references, ref],
|
||||
};
|
||||
});
|
||||
return ref.id;
|
||||
}, []);
|
||||
|
||||
const onRemoveReference = React.useCallback((id: string) => {
|
||||
globalState.setState((state) => {
|
||||
if (!state.references.some((ref) => ref.id === id)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
references: state.references.filter((ref) => ref.id !== id),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClearReferences = React.useCallback(() => {
|
||||
globalState.setState((state) => {
|
||||
if (state.references.length === 0) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, references: [] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onFocus = React.useCallback(() => {
|
||||
notify(eventsRef.current.get('focus'), {});
|
||||
}, []);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -538,9 +686,23 @@ export function AIChatProvider(props: {
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
addReference: onAddReference,
|
||||
removeReference: onRemoveReference,
|
||||
clearReferences: onClearReferences,
|
||||
focus: onFocus,
|
||||
on: onEvent,
|
||||
};
|
||||
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
|
||||
}, [
|
||||
onOpen,
|
||||
onClose,
|
||||
onClear,
|
||||
onPostMessage,
|
||||
onAddReference,
|
||||
onRemoveReference,
|
||||
onClearReferences,
|
||||
onFocus,
|
||||
onEvent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AIChatControllerContext.Provider value={controller}>
|
||||
@@ -560,3 +722,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.responding) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import type { AIToolDefinition } from '@gitbook/api';
|
||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { NavigationStatusContext } from '../hooks';
|
||||
import { normalizePathname, resolveNavigationTarget } from './navigation';
|
||||
import { resolveAINavigationLink } from './server-actions';
|
||||
|
||||
const NavigateToPageInputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
|
||||
* after a timeout. App Router updates `window.location` only when the navigation commits, so this
|
||||
* lets the tool hold its turn until the user is actually on the new page — after which the
|
||||
* tool-result server action's router refresh can no longer cancel the navigation.
|
||||
*/
|
||||
function waitForNavigationCommit(pathname: string): Promise<boolean> {
|
||||
const target = normalizePathname(pathname);
|
||||
if (normalizePathname(window.location.pathname) === target) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
const check = () => {
|
||||
if (normalizePathname(window.location.pathname) === target) {
|
||||
resolve(true);
|
||||
} else if (Date.now() - startedAt > 3000) {
|
||||
resolve(false);
|
||||
} else {
|
||||
requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the built-in `navigateToPage` tool exposed to the assistant.
|
||||
*
|
||||
* The tool opens a page within the current documentation site without confirmation. It navigates
|
||||
* instantly with the Next.js router (adding a browser history entry, so the user can navigate
|
||||
* back) and waits for the navigation to commit before reporting back, so the assistant's
|
||||
* follow-up does not cancel the navigation.
|
||||
*/
|
||||
export function useNavigateToPageTool(): GitBookIntegrationTool {
|
||||
const router = useRouter();
|
||||
const language = useLanguage();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
|
||||
// The tool object is memoized once, so read the latest values from a ref at call time.
|
||||
const ref = React.useRef({ router, language, onNavigationClick });
|
||||
React.useEffect(() => {
|
||||
ref.current = { router, language, onNavigationClick };
|
||||
});
|
||||
|
||||
return React.useMemo<GitBookIntegrationTool>(
|
||||
() => ({
|
||||
name: 'navigateToPage',
|
||||
description:
|
||||
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
|
||||
inputSchema: zodToJsonSchema(
|
||||
NavigateToPageInputSchema as any
|
||||
) as AIToolDefinition['inputSchema'],
|
||||
execute: async (input) => {
|
||||
const { router, language, onNavigationClick } = ref.current;
|
||||
const { url } = NavigateToPageInputSchema.parse(input);
|
||||
|
||||
// The assistant references pages using the stable content-ref scheme
|
||||
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
|
||||
const resolved = await resolveAINavigationLink(url);
|
||||
const target =
|
||||
'error' in resolved
|
||||
? resolved
|
||||
: resolveNavigationTarget(resolved.href, window.location);
|
||||
|
||||
if ('error' in target) {
|
||||
return {
|
||||
output: { error: target.error },
|
||||
summary: {
|
||||
icon: 'triangle-exclamation',
|
||||
text: tString(language, 'ai_chat_tools_navigate_failed'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onNavigationClick(target.href);
|
||||
router.push(target.href);
|
||||
const committed = await waitForNavigationCommit(target.pathname);
|
||||
if (!committed) {
|
||||
// biome-ignore lint/suspicious/noConsole: surfaces a navigation that never committed
|
||||
console.warn(`navigateToPage: navigation to ${target.href} did not commit`);
|
||||
}
|
||||
|
||||
return {
|
||||
output: { navigated: true, url: target.href },
|
||||
summary: {
|
||||
icon: 'book-open',
|
||||
text: tString(language, 'ai_chat_tools_navigated_to_page'),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatState,
|
||||
getAIChatStatus,
|
||||
useAI,
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
@@ -82,12 +83,13 @@ export function AIChat() {
|
||||
}
|
||||
}}
|
||||
withOverlay={true}
|
||||
data-ai-chat
|
||||
className={tcls(
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<EmbeddableFrame className="relative w-full shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat" aria-busy={chat.loading}>
|
||||
<EmbeddableFrameHeader className="not-embed:px-4">
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
@@ -107,7 +109,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 +132,13 @@ export function AIChatDynamicIcon(props: {
|
||||
}) {
|
||||
const { trademark, className } = props;
|
||||
const chat = useAIChatState();
|
||||
const status = getAIChatStatus(chat);
|
||||
|
||||
return (
|
||||
<AIChatIcon
|
||||
className={tcls('size-5 text-tint', className)}
|
||||
trademark={trademark}
|
||||
state={
|
||||
chat.error
|
||||
? 'error'
|
||||
: chat.loading
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
? 'working'
|
||||
: 'thinking'
|
||||
: chat.messages.length > 0
|
||||
? chat.control
|
||||
? 'confirm'
|
||||
: 'done'
|
||||
: 'default'
|
||||
}
|
||||
state={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -160,59 +151,45 @@ export function AIChatSubtitle(props: {
|
||||
}) {
|
||||
const { chat } = props;
|
||||
const language = useLanguage();
|
||||
const status = getAIChatStatus(chat);
|
||||
const subtitleKey =
|
||||
status === 'thinking'
|
||||
? 'ai_chat_thinking'
|
||||
: status === 'exploring'
|
||||
? 'ai_chat_exploring'
|
||||
: status === 'working'
|
||||
? 'ai_chat_working'
|
||||
: status === 'confirm'
|
||||
? 'ai_chat_waiting'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<EmbeddableFrameSubtitle
|
||||
className={tcls(
|
||||
'relative',
|
||||
chat.loading || chat.control ? 'h-3 opacity-11' : 'h-0 opacity-0'
|
||||
)}
|
||||
className={tcls('relative', subtitleKey ? 'h-3 opacity-11' : 'h-0 opacity-0')}
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
{(
|
||||
[
|
||||
['thinking', 'ai_chat_thinking'],
|
||||
['exploring', 'ai_chat_exploring'],
|
||||
['working', 'ai_chat_working'],
|
||||
['confirm', 'ai_chat_waiting'],
|
||||
] as const
|
||||
).map(([candidateStatus, key]) => (
|
||||
<span
|
||||
key={candidateStatus}
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
status === candidateStatus
|
||||
? 'animate-blur-in-display-slow'
|
||||
: 'hidden'
|
||||
: 'animate-blur-out-display-slow'
|
||||
)}
|
||||
style={{
|
||||
animationDelay:
|
||||
chat.messages[chat.messages.length - 1]?.content && !chat.control
|
||||
? '.3s'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{t(language, 'ai_chat_working')}
|
||||
</span>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
? 'animate-blur-out-display-slow'
|
||||
: 'animate-blur-in-display-slow'
|
||||
: 'hidden'
|
||||
)}
|
||||
style={{
|
||||
animationDelay:
|
||||
chat.messages[chat.messages.length - 1]?.content || chat.control
|
||||
? undefined
|
||||
: '.3s',
|
||||
}}
|
||||
>
|
||||
{t(language, 'ai_chat_thinking')}
|
||||
</span>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
chat.control ? 'animate-blur-in-display-slow' : 'animate-blur-out-display-slow'
|
||||
)}
|
||||
style={{ animationDelay: chat.control ? '.3s' : undefined }}
|
||||
>
|
||||
{t(language, 'ai_chat_waiting')}
|
||||
</span>
|
||||
: 'animate-blur-out-display-slow'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: status === candidateStatus ? '.3s' : undefined,
|
||||
}}
|
||||
>
|
||||
{subtitleKey ? t(language, key) : null}
|
||||
</span>
|
||||
))}
|
||||
</EmbeddableFrameSubtitle>
|
||||
);
|
||||
}
|
||||
@@ -249,8 +226,8 @@ export function AIChatBody(props: {
|
||||
return (
|
||||
<>
|
||||
<ScrollContainer
|
||||
className="min-h-[20%] shrink grow animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4 [scroll-behavior:smooth]"
|
||||
className="min-h-[20%] max-w-full shrink grow animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="py-4 gutter-stable flex flex-col gap-4 not-embed:px-4 [scroll-behavior:smooth]"
|
||||
orientation="vertical"
|
||||
trailing={{ fade: false, button: true }}
|
||||
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
@@ -298,14 +275,14 @@ export function AIChatBody(props: {
|
||||
)}
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 pb-4">
|
||||
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
{chat.control ? <AIChatControl control={chat.control} /> : null}
|
||||
<AIChatInput
|
||||
loading={chat.loading}
|
||||
disabled={chat.loading || chat.error}
|
||||
responding={chat.responding}
|
||||
disabled={chat.responding || chat.error}
|
||||
onSubmit={(value) => {
|
||||
chatController.postMessage({ message: value });
|
||||
}}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import type { Assistant } from '../AI';
|
||||
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.
|
||||
@@ -38,6 +38,7 @@ export function AIChatButton(props: {
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type React from 'react';
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
size?: number;
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
state?:
|
||||
| 'default'
|
||||
| 'intro'
|
||||
| 'thinking'
|
||||
| 'exploring'
|
||||
| 'working'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'confirm';
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +33,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -43,7 +52,7 @@ export function AIChatIcon({
|
||||
viewBox="0 0 16 16"
|
||||
preserveAspectRatio="xMaxYMid meet"
|
||||
className={className}
|
||||
aria-busy={state === 'thinking'}
|
||||
aria-busy={['thinking', 'exploring', 'working'].includes(state)}
|
||||
overflow="visible"
|
||||
{...props}
|
||||
fill="none"
|
||||
@@ -63,6 +72,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)',
|
||||
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
exploring: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -124,6 +134,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: '',
|
||||
thinking: '',
|
||||
exploring: '',
|
||||
working: '',
|
||||
done: '',
|
||||
confirm: 'bounceSmall 1s infinite both',
|
||||
@@ -146,6 +157,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'fadeIn 2s forwards',
|
||||
thinking: '',
|
||||
exploring: '',
|
||||
working: '',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -186,6 +198,7 @@ export function AIChatIcon({
|
||||
animation: {
|
||||
intro: 'pathEnter 1.5s both ease-out',
|
||||
thinking: 'pathLoading 2s infinite both',
|
||||
exploring: 'pathLoading 2s infinite both',
|
||||
working: 'pathLoading 2s infinite both',
|
||||
done: 'pathEnter 1s forwards ease',
|
||||
confirm: '',
|
||||
@@ -201,7 +214,15 @@ export function AIChatIcon({
|
||||
|
||||
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
className?: string;
|
||||
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||
state?:
|
||||
| 'default'
|
||||
| 'intro'
|
||||
| 'thinking'
|
||||
| 'exploring'
|
||||
| 'working'
|
||||
| 'done'
|
||||
| 'error'
|
||||
| 'confirm';
|
||||
}
|
||||
|
||||
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
|
||||
@@ -213,6 +234,7 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
|
||||
intro: 'fadeIn 1s both, orbit 1s cubic-bezier(0.16,1,0.3,1)',
|
||||
thinking:
|
||||
'fadeIn 1s both, orbit 1s orbit 2s 1s infinite forwards linear ease-out,',
|
||||
exploring: '',
|
||||
working: '',
|
||||
done: '',
|
||||
confirm: '',
|
||||
@@ -234,6 +256,7 @@ export function AISearchIcon({ className = 'size-4', state = 'default' }: AISear
|
||||
animation: {
|
||||
intro: 'spin 2s .5s forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
exploring: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||
done: '',
|
||||
confirm: '',
|
||||
|
||||
@@ -3,27 +3,29 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAIChatState } from '../AI/useAIChat';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
import { Input } from '../primitives/Input';
|
||||
import { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
|
||||
export function AIChatInput(props: {
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When true, the input is disabled
|
||||
*/
|
||||
loading: boolean;
|
||||
responding: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
}) {
|
||||
const { onSubmit, disabled, loading } = props;
|
||||
const { onSubmit, disabled, responding } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.opened && !disabled && !loading) {
|
||||
if (chat.opened && !disabled && !responding) {
|
||||
// Add a small delay to ensure the input is rendered before focusing
|
||||
// This fixes inconsistent focus behaviour across browsers
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -32,7 +34,14 @@ export function AIChatInput(props: {
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [disabled, loading, chat.opened]);
|
||||
}, [disabled, responding, chat.opened]);
|
||||
|
||||
// Explicit focus requests (e.g. clicking "Ask" while the chat is already open).
|
||||
useEffect(() => {
|
||||
return chatController.on('focus', () => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
}, [chatController]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
@@ -66,16 +75,23 @@ export function AIChatInput(props: {
|
||||
rows={1}
|
||||
maxLength={2048}
|
||||
keyboardShortcut={
|
||||
!disabled && !loading
|
||||
!disabled && !responding
|
||||
? {
|
||||
keys: ['mod', 'i'],
|
||||
className: 'bg-tint-base group-focus-within/input:hidden',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled || loading || chat.control !== null}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || responding || chat.control !== null}
|
||||
aria-busy={responding}
|
||||
ref={inputRef}
|
||||
header={
|
||||
<AIChatReferenceChips
|
||||
references={chat.references}
|
||||
onRemove={chatController.removeReference}
|
||||
disabled={responding || disabled}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<HoverCardRoot openDelay={500}>
|
||||
<HoverCard
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
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 { Fragment } from 'react';
|
||||
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 { AIChatReferenceChips } from './AIChatReferenceChips';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
@@ -11,10 +21,11 @@ export function AIChatMessages(props: {
|
||||
chatController: AIChatController;
|
||||
}) {
|
||||
const { chat, chatController } = props;
|
||||
const status = getAIChatStatus(chat);
|
||||
const showLoadingShim = chat.responding && 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,8 +47,33 @@ export function AIChatMessages(props: {
|
||||
messageGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
return messageGroups.map((group, groupIndex) => {
|
||||
const isLastGroup = group === messageGroups[messageGroups.length - 1];
|
||||
|
||||
const userItems = group.filter(({ message }) => message.role === AIMessageRole.User);
|
||||
const assistantItems = group.filter(
|
||||
({ message }) => message.role === AIMessageRole.Assistant
|
||||
);
|
||||
|
||||
// Each client-side tool call splits a turn into a separate assistant message, so without
|
||||
// aggregating here every tool would render its own "Explored with 1 tool" expandable.
|
||||
// We collapse the whole assistant run behind a single expandable instead, reusing the
|
||||
// commentary (hidden) vs final-answer (visible) distinction from AIMessageView.
|
||||
const toolCount = assistantItems.reduce(
|
||||
(acc, { message }) => acc + (message.activity?.toolCount ?? 0),
|
||||
0
|
||||
);
|
||||
const hasCommentary =
|
||||
assistantItems.some(({ message }) => message.activity?.hasCommentary) || toolCount > 0;
|
||||
const hasFinalAnswer =
|
||||
assistantItems.some(({ message }) => message.activity?.hasFinalAnswer) || !isLastGroup;
|
||||
|
||||
const firstAssistantIndex = assistantItems.at(0)?.originalIndex ?? -1;
|
||||
const lastAssistantIndex = assistantItems.at(-1)?.originalIndex ?? -1;
|
||||
const isLastMessage = lastAssistantIndex === chat.messages.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupIndex}
|
||||
@@ -50,69 +86,125 @@ export function AIChatMessages(props: {
|
||||
)}
|
||||
style={{ animationDelay: '.2s' }}
|
||||
>
|
||||
{group.map(({ message, originalIndex }) => {
|
||||
const isLastMessage = originalIndex === chat.messages.length - 1;
|
||||
return (
|
||||
{userItems.map(({ message, originalIndex }) => (
|
||||
<Fragment key={originalIndex}>
|
||||
{message.references?.length ? (
|
||||
<div className="flex max-w-[80%] origin-top-right justify-end self-end">
|
||||
<AIChatReferenceChips references={message.references} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
key={originalIndex}
|
||||
data-testid={
|
||||
message.role === AIMessageRole.User
|
||||
? 'ai-chat-message-user'
|
||||
: 'ai-chat-message-assistant'
|
||||
}
|
||||
data-testid="ai-chat-message-user"
|
||||
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',
|
||||
isLastMessage && message.role === AIMessageRole.Assistant
|
||||
? 'grow'
|
||||
: ''
|
||||
'mb-4 max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{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'
|
||||
)}
|
||||
>
|
||||
<HoldMessage className={message.content ? 'hidden' : ''} />
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{assistantItems.length > 0 ? (
|
||||
<Collapsible
|
||||
open={!hasFinalAnswer}
|
||||
disabled={!hasFinalAnswer}
|
||||
data-testid="ai-chat-message-assistant"
|
||||
id={`message-${firstAssistantIndex}`}
|
||||
className={tcls(
|
||||
'flex flex-col gap-2',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
'origin-top-left text-tint-strong',
|
||||
isLastMessage ? 'grow' : ''
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(firstAssistantIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<span data-testid="ai-chat-activity-summary">
|
||||
{toolCount > 0
|
||||
? t(
|
||||
language,
|
||||
'ai_chat_explored_with',
|
||||
tString(
|
||||
language,
|
||||
toolCount === 1
|
||||
? 'tool_count'
|
||||
: 'tool_count_plural',
|
||||
toolCount.toString()
|
||||
)
|
||||
)
|
||||
: t(language, 'ai_chat_explored')}
|
||||
</span>
|
||||
<ToggleChevron orientation="right-to-down" />
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
|
||||
{assistantItems.map(({ message, originalIndex }) => (
|
||||
<Fragment key={originalIndex}>{message.content}</Fragment>
|
||||
))}
|
||||
|
||||
{isLastMessage ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'mt-4 flex w-full shrink-0 flex-col gap-2 overflow-hidden starting:opacity-0 transition-all transition-discrete duration-300',
|
||||
showLoadingShim
|
||||
? 'max-h-48 opacity-11'
|
||||
: 'pointer-events-none max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage
|
||||
className={
|
||||
assistantItems.some(({ message }) => message.content)
|
||||
? 'hidden'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.responding &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
!chat.control ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1.5 -mt-4 mb-2"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { AIChatReference } from '../AI/references';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
const triggerClassName =
|
||||
'inline-flex min-w-0 items-center gap-1.5 circular-corners:rounded-2xl rounded-corners:rounded-sm py-0.5 pr-1 pl-1.5 text-tint no-underline transition hover:bg-tint hover:text-tint';
|
||||
|
||||
export function AIChatReferenceChips(props: {
|
||||
references: AIChatReference[];
|
||||
onRemove?: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { references, onRemove, disabled } = props;
|
||||
|
||||
if (references.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5">
|
||||
{references.map((ref) => {
|
||||
const content = (
|
||||
<>
|
||||
<Icon icon={getReferenceIcon(ref)} className="size-3 shrink-0 opacity-7" />
|
||||
<span
|
||||
className={tcls(
|
||||
'min-w-0 truncate',
|
||||
ref.type === 'code-block' && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{ref.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="inline-flex max-w-52 items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md straight-corners:rounded-xs border border-tint-subtle bg-tint-base px-0.5 py-0.5 text-tint text-xs leading-none"
|
||||
>
|
||||
{ref.type === 'page' && ref.href ? (
|
||||
// A page reference may be clicked from anywhere: render a link so it
|
||||
// navigates back to the page (and supports cmd/ctrl-click to open in a
|
||||
// new tab).
|
||||
<Link
|
||||
href={ref.href}
|
||||
prefetch={false}
|
||||
className={triggerClassName}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
focusReference(ref);
|
||||
}}
|
||||
className={triggerClassName}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove(ref.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center circular-corners:rounded-full rounded-corners:rounded-sm text-tint/8 transition hover:bg-tint hover:text-tint-strong disabled:cursor-not-allowed disabled:opacity-5"
|
||||
>
|
||||
<Icon icon="xmark" className="size-2.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getReferenceIcon(ref: AIChatReference): IconName {
|
||||
switch (ref.type) {
|
||||
case 'code-block':
|
||||
return 'code';
|
||||
case 'page':
|
||||
return 'memo';
|
||||
default:
|
||||
assertNever(ref);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the content a reference points at, for chips that aren't rendered as links:
|
||||
* - a code block: scroll it into view on the current page and focus it;
|
||||
* - a page without a known href: scroll back to the top (the reader is most likely on it).
|
||||
*/
|
||||
function focusReference(ref: AIChatReference) {
|
||||
if (ref.type === 'page') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = document.querySelectorAll<HTMLElement>(`#${CSS.escape(ref.id)}`);
|
||||
const target = Array.from(candidates).find((el) => !el.closest('[data-ai-chat]'));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.querySelector<HTMLElement>('[data-codeblock-focus]')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCurrentContent } from '@/components/hooks';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { AIChatController } from '../AI';
|
||||
import { useRecentSearchQueries } from '../Search/recent-queries';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
export default function AIChatSuggestedQuestions(props: {
|
||||
@@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const language = useLanguage();
|
||||
const { chatController, suggestions: _suggestions } = props;
|
||||
const { siteSpaceId } = useCurrentContent();
|
||||
const recentQueries = useRecentSearchQueries(siteSpaceId ?? '');
|
||||
const { chatController, suggestions: configuredSuggestions } = props;
|
||||
|
||||
const suggestions =
|
||||
_suggestions && _suggestions.length > 0
|
||||
? _suggestions
|
||||
: [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const defaultSuggestions = [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const baseSuggestions =
|
||||
configuredSuggestions && configuredSuggestions.length > 0
|
||||
? configuredSuggestions
|
||||
: defaultSuggestions;
|
||||
|
||||
const suggestions = [
|
||||
...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query),
|
||||
...baseSuggestions,
|
||||
].reduce<string[]>((acc, suggestion) => {
|
||||
if (acc.includes(suggestion)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(suggestion);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ToolbarVisibilityHint(props: ToolbarVisibilityHintProps) {
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
}}
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3 py-0.5"
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-[calc(100%+2px)] left-1/2 flex items-center gap-1.5 rounded-lg border border-[#eaeaea] border-solid bg-white py-1 pr-1 pl-2"
|
||||
>
|
||||
<span className="whitespace-nowrap text-[11px] text-neutral-9">
|
||||
This toolbar appears only when signed in to GitBook.
|
||||
|
||||
@@ -132,7 +132,7 @@ function AdSponsoredLink(props: { spaceId: string }) {
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
|
||||
return (
|
||||
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
|
||||
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint')}>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={viaUrl.toString()}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
|
||||
arrowProps={{ className: 'fill-tint-1' }}
|
||||
>
|
||||
<dfn
|
||||
data-testid="annotation-button"
|
||||
data-annotation=""
|
||||
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
|
||||
tabIndex={0}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DocumentBlockEmbed,
|
||||
DocumentBlockFile,
|
||||
DocumentBlockImage,
|
||||
DocumentBlockImages,
|
||||
JSONDocument,
|
||||
} from '@gitbook/api';
|
||||
|
||||
@@ -12,6 +13,14 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { Inlines } from './Inlines';
|
||||
|
||||
export type CaptionAlign = NonNullable<DocumentBlockImages['data']['align']>;
|
||||
|
||||
const captionAlignStyles = {
|
||||
center: 'mx-auto',
|
||||
left: 'mr-auto ml-0',
|
||||
right: 'mr-0 ml-auto',
|
||||
} satisfies Record<CaptionAlign, ClassValue>;
|
||||
|
||||
/**
|
||||
* Wrap a content of a block that has a potential caption.
|
||||
*/
|
||||
@@ -22,6 +31,7 @@ export function Caption(
|
||||
style?: ClassValue;
|
||||
fit?: boolean;
|
||||
wrapperStyle?: ClassValue;
|
||||
align?: CaptionAlign;
|
||||
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
|
||||
withBorder?: boolean;
|
||||
withFrame?: boolean;
|
||||
@@ -49,6 +59,7 @@ export function Caption(
|
||||
withFrame && 'p-2',
|
||||
],
|
||||
style,
|
||||
align = 'center',
|
||||
} = props;
|
||||
|
||||
const caption = getNodeFragmentByName(block, 'caption');
|
||||
@@ -64,7 +75,7 @@ export function Caption(
|
||||
|
||||
return (
|
||||
<picture className={tcls('relative', style)}>
|
||||
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
|
||||
<div className={tcls(wrapperStyle, captionAlignStyles[align])}>{children}</div>
|
||||
<figcaption
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { AIChatIcon } from '@/components/AIChat';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getCodeTextFromId } from './utils';
|
||||
|
||||
export function AskAICodeButton(props: {
|
||||
codeId: string;
|
||||
title?: string;
|
||||
syntax?: string;
|
||||
style: ClassValue;
|
||||
}) {
|
||||
const { codeId, title, syntax, style } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const config = useAIConfig();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
if (config.aiMode !== CustomizationAIMode.Assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
const codeText = getCodeTextFromId(codeId);
|
||||
if (!codeText?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatController.addReference({
|
||||
type: 'code-block',
|
||||
id: codeId,
|
||||
label: title ?? 'Code',
|
||||
content: codeText,
|
||||
syntax,
|
||||
});
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
icon={<AIChatIcon state="default" trademark={config.trademark} className="" />}
|
||||
onClick={onClick}
|
||||
className={tcls(style, 'translate-y-0!', 'print:hidden', '[[data-ai-chat]_&]:hidden')}
|
||||
>
|
||||
{t(language, 'ask')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -141,6 +141,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
style={style}
|
||||
theme={theme ?? plainTheme}
|
||||
id={codeBlockBodyId}
|
||||
isPrint={mode === 'print'}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -81,7 +81,14 @@ export async function CodeBlock(
|
||||
const theme = await highlight(block, richInlines, {
|
||||
themes: themes,
|
||||
});
|
||||
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
|
||||
return (
|
||||
<CodeBlockRenderer
|
||||
block={block}
|
||||
style={style}
|
||||
theme={theme}
|
||||
isPrint={context.mode === 'print'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const variables = context.contentContext
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { AskAICodeButton } from './AskAICodeButton';
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
|
||||
|
||||
@@ -13,6 +14,11 @@ type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'sty
|
||||
theme: HighlightTheme;
|
||||
'aria-busy'?: boolean;
|
||||
id?: string;
|
||||
/**
|
||||
* Whether the block is rendered for print/PDF.
|
||||
* When true, the "Ask AI" button is not rendered.
|
||||
*/
|
||||
isPrint?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,7 +28,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
props: CodeBlockRendererProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { block, style, theme, 'aria-busy': ariaBusy } = props;
|
||||
const { block, style, theme, 'aria-busy': ariaBusy, isPrint } = props;
|
||||
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
@@ -39,15 +45,24 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={codeId}
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls(
|
||||
'group/codeblock shiki relative flex shrink flex-col overflow-hidden print:overflow-visible',
|
||||
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
|
||||
'[&:has([data-codeblock-focus]:focus)]:ring-2 [&:has([data-codeblock-focus]:focus)]:ring-primary-hover',
|
||||
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}`}
|
||||
>
|
||||
<span
|
||||
data-codeblock-focus
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute size-0 outline-none"
|
||||
/>
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
{title ? (
|
||||
<div
|
||||
@@ -63,11 +78,18 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
<div className="relative flex min-h-0 flex-col">
|
||||
<div className="absolute top-2 right-2 z-2 flex items-start gap-1.5 font-sans leading-none opacity-0 group-hover/codeblock:opacity-11 has-[button:focus-visible]:opacity-11">
|
||||
{!isPrint ? (
|
||||
<AskAICodeButton
|
||||
codeId={codeId}
|
||||
title={title}
|
||||
syntax={block.data.syntax}
|
||||
style="backdrop-blur-md"
|
||||
/>
|
||||
) : null}
|
||||
<CopyCodeButton codeId={codeId} style="backdrop-blur-md" />
|
||||
</div>
|
||||
<pre
|
||||
className={tcls(
|
||||
'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',
|
||||
@@ -82,7 +104,6 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
}}
|
||||
>
|
||||
<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',
|
||||
@@ -114,27 +135,30 @@ function CodeHighlightLine(props: {
|
||||
withLineNumbers: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers, bg, fg } = props;
|
||||
const lineStyle = {
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={tcls('highlight-line', line.highlighted && 'highlighted')}
|
||||
style={{
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
}}
|
||||
>
|
||||
{withLineNumbers && (
|
||||
<span
|
||||
className="highlight-line-number"
|
||||
style={{
|
||||
color: fg?.color,
|
||||
...fg?.vars,
|
||||
backgroundColor: bg?.color,
|
||||
...bg?.vars,
|
||||
}}
|
||||
/>
|
||||
className={tcls(
|
||||
'highlight-line',
|
||||
line.diff === 'added' && 'diff-added',
|
||||
line.diff === 'deleted' && 'diff-deleted',
|
||||
line.highlighted && 'highlighted'
|
||||
)}
|
||||
aria-label={
|
||||
line.diff === 'added'
|
||||
? 'Added line'
|
||||
: line.diff === 'deleted'
|
||||
? 'Removed line'
|
||||
: undefined
|
||||
}
|
||||
style={lineStyle}
|
||||
>
|
||||
{withLineNumbers && <span className="highlight-line-number" style={lineStyle} />}
|
||||
<span className="highlight-line-content">
|
||||
<CodeHighlightTokens tokens={line.tokens} />
|
||||
{!isLast && '\n'}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getCodeTextFromId } from './utils';
|
||||
|
||||
/**
|
||||
* Client component to copy the code of a code block.
|
||||
* To avoid passing large payload to the client, the code is computed from the DOM.
|
||||
@@ -31,12 +33,12 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
||||
}, [copied]);
|
||||
|
||||
const onClick = () => {
|
||||
const element = document.getElementById(codeId);
|
||||
if (!element) {
|
||||
const codeText = getCodeTextFromId(codeId);
|
||||
if (codeText === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(getCodeText(element));
|
||||
navigator.clipboard.writeText(codeText);
|
||||
|
||||
setCopied(true);
|
||||
};
|
||||
@@ -52,31 +54,3 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the code text from the DOM,
|
||||
* ignoring the empty white space we use for empty lines (represented with a class "ew").
|
||||
*/
|
||||
function getCodeText(code: HTMLElement): string {
|
||||
let text = '';
|
||||
|
||||
const iterate = (node: Node) => {
|
||||
if (node instanceof HTMLBRElement) {
|
||||
text += '\n';
|
||||
} else if (node instanceof HTMLSpanElement) {
|
||||
if (node.classList.contains('ew')) {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += node.innerText;
|
||||
}
|
||||
} else if (node instanceof HTMLElement) {
|
||||
node.childNodes.forEach(iterate);
|
||||
} else {
|
||||
text += node.textContent;
|
||||
}
|
||||
};
|
||||
|
||||
iterate(code);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
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 { FocusScope, usePreventScroll } from 'react-aria';
|
||||
import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { MermaidPanZoomControls } from './MermaidPanZoomControls';
|
||||
import { getPlainCodeBlock } from './highlight';
|
||||
|
||||
/** Duration of the fullscreen dialog enter/exit animation, must match `animate-blur-in/out`. */
|
||||
const DIALOG_ANIMATION_MS = 200;
|
||||
|
||||
/**
|
||||
* 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 panelRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
// A stable container that holds the diagram subtree. We portal the diagram into it and
|
||||
// only ever move this plain node between the inline slot and the dialog — never the
|
||||
// React-managed subtree itself — so React stays in control and panzoom/SVG are preserved.
|
||||
const diagramHostRef = useRef<HTMLDivElement | null>(null);
|
||||
if (diagramHostRef.current === null && typeof document !== 'undefined') {
|
||||
const host = document.createElement('div');
|
||||
// `display: contents` so the host adds no box of its own (the diagram becomes a
|
||||
// direct flex child of the dialog panel and can fill it).
|
||||
host.style.display = 'contents';
|
||||
diagramHostRef.current = host;
|
||||
}
|
||||
const [panZoom, setPanZoom] = useState<ReturnType<typeof Panzoom> | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// `isFullscreen` is the open intent; `isExiting` keeps the dialog mounted while it
|
||||
// animates closed. `isPresent` is true whenever the diagram lives in the dialog.
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const isPresent = isFullscreen || isExiting;
|
||||
const { resolvedTheme } = useTheme();
|
||||
const darkMode = resolvedTheme === 'dark';
|
||||
const id = useSafeId();
|
||||
const hasBeenInViewport = useHasBeenInViewport(rootRef, { rootMargin: '800px' });
|
||||
const shouldRender = mode === 'print' || hasBeenInViewport;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = diagramRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!container || !wrapper) {
|
||||
@@ -36,57 +67,165 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
let cleanupPanZoom: (() => void) | undefined;
|
||||
setError(false);
|
||||
setIsLoading(true);
|
||||
setPanZoom(null);
|
||||
container.innerHTML = '';
|
||||
|
||||
const cancelScheduledRender = scheduleMermaidWork(() => {
|
||||
enqueueMermaidRender(async () => {
|
||||
if (cancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderMermaidDiagram({
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.svg;
|
||||
if (container.querySelector('svg')) {
|
||||
result.bindFunctions?.(container);
|
||||
}
|
||||
|
||||
renderMermaidDiagram({
|
||||
container,
|
||||
source,
|
||||
id,
|
||||
darkMode,
|
||||
})
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
cleanupPanZoom = initPanzoom({
|
||||
container,
|
||||
wrapper,
|
||||
onInit: setPanZoom,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelScheduledRender();
|
||||
cleanupPanZoom?.();
|
||||
setPanZoom(null);
|
||||
};
|
||||
}, [source, id, darkMode]);
|
||||
}, [source, id, darkMode, shouldRender]);
|
||||
|
||||
// Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS).
|
||||
usePreventScroll({ isDisabled: !isPresent });
|
||||
|
||||
const openFullscreen = useCallback(() => {
|
||||
// Reserve the inline slot's current height before the diagram is detached, so the
|
||||
// page layout does not jump. Measured here while still inline and un-restyled.
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
root.style.minHeight = `${root.offsetHeight}px`;
|
||||
}
|
||||
setIsExiting(false);
|
||||
setIsFullscreen(true);
|
||||
// Re-center the diagram for the larger view.
|
||||
panZoom?.reset();
|
||||
}, [panZoom]);
|
||||
|
||||
const closeFullscreen = useCallback(() => {
|
||||
setIsFullscreen(false);
|
||||
setIsExiting(true);
|
||||
}, []);
|
||||
|
||||
// Keep the dialog mounted until the exit animation finishes, then unmount it.
|
||||
useEffect(() => {
|
||||
if (!isExiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsExiting(false);
|
||||
panZoom?.reset();
|
||||
}, DIALOG_ANIMATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isExiting, panZoom]);
|
||||
|
||||
// Allow Escape to close the dialog.
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isFullscreen, closeFullscreen]);
|
||||
|
||||
// Keep the diagram host in the inline slot on mount (and whenever it isn't in the dialog).
|
||||
useLayoutEffect(() => {
|
||||
const host = diagramHostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (host && root && !host.parentNode) {
|
||||
root.appendChild(host);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Move the diagram host into the dialog panel (and back) as the panel mounts/unmounts.
|
||||
// Done in the panel's ref callback so it happens during commit, before FocusScope reads
|
||||
// focus. The inline slot's reserved height (set in openFullscreen) is cleared on return.
|
||||
const setPanel = useCallback((panel: HTMLDivElement | null) => {
|
||||
panelRef.current = panel;
|
||||
const host = diagramHostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel) {
|
||||
panel.appendChild(host);
|
||||
} else if (root) {
|
||||
root.appendChild(host);
|
||||
root.style.minHeight = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return <ClientCodeBlock {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tcls('relative', style)} contentEditable={false}>
|
||||
// The live diagram subtree. It is portaled into a stable host that moves between the
|
||||
// inline slot and the dialog, so its markup must not depend on where it currently lives.
|
||||
const diagram = (
|
||||
<div
|
||||
className={tcls(
|
||||
'group/mermaid relative',
|
||||
isPresent ? 'flex h-full w-full flex-col' : null
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={
|
||||
className={tcls(
|
||||
isLoading
|
||||
? 'invisible absolute inset-x-0 overflow-hidden'
|
||||
: 'cursor-grab overflow-hidden active:cursor-grabbing'
|
||||
}
|
||||
: 'cursor-grab overflow-hidden active:cursor-grabbing',
|
||||
isPresent && !isLoading ? 'flex-1' : null
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={diagramRef}
|
||||
className="overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full"
|
||||
className={tcls(
|
||||
'overflow-auto p-2 [&_svg]:h-auto [&_svg]:max-w-full',
|
||||
isPresent
|
||||
? 'flex h-full items-center justify-center [&_svg]:max-h-full'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
@@ -94,24 +233,62 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
|
||||
<Loading className="h-8 w-8" />
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && panZoom ? <MermaidPanZoomControls panZoom={panZoom} /> : null}
|
||||
{!isLoading && panZoom ? (
|
||||
<MermaidPanZoomControls
|
||||
panZoom={panZoom}
|
||||
isFullscreen={isPresent}
|
||||
onToggleFullscreen={isFullscreen ? closeFullscreen : openFullscreen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */}
|
||||
<div ref={rootRef} className={tcls('relative', style)} contentEditable={false} />
|
||||
{diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null}
|
||||
{isPresent
|
||||
? createPortal(
|
||||
<FocusScope contain restoreFocus>
|
||||
{/* Backdrop: dims and blurs the page, closes on click. */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={tcls(
|
||||
'fixed inset-0 z-40 bg-tint-base/3 backdrop-blur-md dark:bg-tint-base/6',
|
||||
isFullscreen ? 'animate-fade-in' : 'animate-fade-out'
|
||||
)}
|
||||
onClick={closeFullscreen}
|
||||
/>
|
||||
{/* Centered panel. The padding area lets clicks fall through to the backdrop. */}
|
||||
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center p-3 sm:p-5 lg:p-8">
|
||||
<div
|
||||
ref={setPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid diagram"
|
||||
className={tcls(
|
||||
'pointer-events-auto relative flex h-full w-full max-w-[110rem] flex-col overflow-hidden rounded-2xl border border-tint-subtle bg-tint-base shadow-2xl',
|
||||
isFullscreen ? 'animate-blur-in' : 'animate-blur-out'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FocusScope>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -120,11 +297,115 @@ 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.
|
||||
*/
|
||||
@@ -138,7 +419,6 @@ function initPanzoom(args: {
|
||||
const instance = Panzoom(container, {
|
||||
maxScale: 5,
|
||||
minScale: 0.5,
|
||||
contain: 'outside',
|
||||
cursor: 'grab',
|
||||
panOnlyWhenZoomed: true,
|
||||
});
|
||||
|
||||
@@ -3,77 +3,54 @@
|
||||
import type { PanzoomObject } from '@panzoom/panzoom';
|
||||
|
||||
import { Button } from '@/components/primitives';
|
||||
|
||||
const PAN_STEP = 50;
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Navigation and zoom controls for mermaid diagrams, positioned as an overlay.
|
||||
* Zoom and fullscreen controls for mermaid diagrams, grouped into a single vertical
|
||||
* toolbar positioned in the bottom-right corner. Panning is done by dragging the diagram.
|
||||
*/
|
||||
export function MermaidPanZoomControls(props: { panZoom: PanzoomObject }) {
|
||||
const { panZoom } = props;
|
||||
export function MermaidPanZoomControls(props: {
|
||||
panZoom: PanzoomObject;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const { panZoom, isFullscreen, onToggleFullscreen } = props;
|
||||
const btnProps = {
|
||||
variant: 'secondary' as const,
|
||||
size: 'xsmall' as const,
|
||||
iconOnly: true,
|
||||
className: tcls('p-1 [&_svg]:size-3.5 opacity-90'),
|
||||
// Non-interactive tooltips: the Tooltip primitive makes their popper wrapper
|
||||
// pointer-transparent so it can't steal the hover that reveals these controls.
|
||||
tooltipProps: {
|
||||
rootProps: { disableHoverableContent: true },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 bottom-3 z-10 grid grid-cols-3 gap-0.5">
|
||||
{/* Row 1: empty, pan up, zoom in */}
|
||||
<div />
|
||||
<Button
|
||||
label="Pan up"
|
||||
icon="chevron-up"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(0, PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button
|
||||
label="Zoom in"
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.zoomIn()}
|
||||
/>
|
||||
{/* Row 2: pan left, reset, pan right */}
|
||||
<Button
|
||||
label="Pan left"
|
||||
icon="chevron-left"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute right-2 bottom-2 z-10 flex flex-col items-center gap-0.5 rounded-lg 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',
|
||||
// Keep the controls always visible in fullscreen, otherwise only reveal on hover/focus.
|
||||
isFullscreen ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<Button {...btnProps} icon="plus" label="Zoom in" onClick={() => panZoom.zoomIn()} />
|
||||
<Button
|
||||
{...btnProps}
|
||||
icon="arrows-to-dot"
|
||||
label="Reset view"
|
||||
icon="refresh"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.reset()}
|
||||
/>
|
||||
<Button {...btnProps} icon="minus" label="Zoom out" onClick={() => panZoom.zoomOut()} />
|
||||
|
||||
<div className="my-0.5 h-px w-4 bg-tint-subtle" />
|
||||
|
||||
<Button
|
||||
label="Pan right"
|
||||
icon="chevron-right"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(-PAN_STEP, 0, { relative: true })}
|
||||
/>
|
||||
{/* Row 3: empty, pan down, zoom out */}
|
||||
<div />
|
||||
<Button
|
||||
label="Pan down"
|
||||
icon="chevron-down"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.pan(0, -PAN_STEP, { relative: true })}
|
||||
/>
|
||||
<Button
|
||||
label="Zoom out"
|
||||
icon="minus"
|
||||
variant="secondary"
|
||||
size="xsmall"
|
||||
iconOnly
|
||||
onClick={() => panZoom.zoomOut()}
|
||||
{...btnProps}
|
||||
icon={isFullscreen ? 'compress' : 'expand'}
|
||||
label={isFullscreen ? 'Exit full page' : 'View in full page'}
|
||||
onClick={onToggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@gitbook/api';
|
||||
import { useId } from 'react';
|
||||
|
||||
import type { DocumentContext } from '../DocumentView';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { convertCodeStringToBlock } from './utils';
|
||||
|
||||
@@ -15,10 +16,11 @@ import { convertCodeStringToBlock } from './utils';
|
||||
export function PlainCodeBlock(props: {
|
||||
code: string;
|
||||
syntax: string;
|
||||
mode?: DocumentContext['mode'];
|
||||
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
|
||||
themes?: CustomizationThemedCodeTheme;
|
||||
}) {
|
||||
const { code, syntax, themeKey, themes } = props;
|
||||
const { code, syntax, mode = 'default', themeKey, themes } = props;
|
||||
const id = useId();
|
||||
|
||||
const block = convertCodeStringToBlock({ key: id, code, syntax });
|
||||
@@ -33,7 +35,7 @@ export function PlainCodeBlock(props: {
|
||||
<CodeBlock
|
||||
document={document}
|
||||
context={{
|
||||
mode: 'default',
|
||||
mode,
|
||||
}}
|
||||
themeKey={themeKey}
|
||||
themes={themes}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { type RenderedInline, getInlines, highlight } from './highlight';
|
||||
import {
|
||||
type HighlightLine,
|
||||
type HighlightToken,
|
||||
type RenderedInline,
|
||||
getInlines,
|
||||
highlight,
|
||||
} from './highlight';
|
||||
|
||||
async function highlightWithInlines(block: DocumentBlockCode) {
|
||||
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
|
||||
@@ -690,6 +696,138 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
function joinLineContent(line: HighlightLine): string {
|
||||
const visit = (tokens: HighlightToken[]): string =>
|
||||
tokens
|
||||
.map((t) => {
|
||||
if (t.type === 'plain') return t.content;
|
||||
if (t.type === 'shiki') return t.token.content;
|
||||
return visit(t.children);
|
||||
})
|
||||
.join('');
|
||||
return visit(line.tokens);
|
||||
}
|
||||
|
||||
function singleLineBlock(syntax: string | undefined, text: string): DocumentBlockCode {
|
||||
return {
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: syntax ? { syntax } : {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('classifies and strips trailing // [!code ++] in JS', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('javascript', 'const a = 1 // [!code ++]')
|
||||
);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('const a = 1');
|
||||
});
|
||||
|
||||
it('classifies and strips trailing # [!code --] in Python', async () => {
|
||||
const lines = await highlightWithInlines(singleLineBlock('python', 'x = 1 # [!code --]'));
|
||||
expect(lines[0]!.diff).toBe('deleted');
|
||||
expect(joinLineContent(lines[0]!)).toBe('x = 1');
|
||||
});
|
||||
|
||||
it('classifies and strips <!-- [!code ++] --> in HTML', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('html', '<div></div> <!-- [!code ++] -->')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('<div></div>');
|
||||
});
|
||||
|
||||
it('classifies and strips /* [!code --] */ in CSS', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('css', '.a { color: red; } /* [!code --] */')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('deleted');
|
||||
expect(joinLineContent(lines[0]!)).toBe('.a { color: red; }');
|
||||
});
|
||||
|
||||
it('does not classify when marker is not at end of line', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock('javascript', 'const x = 1 // [!code ++] trailing')
|
||||
);
|
||||
expect(lines[0]!.diff).toBeNull();
|
||||
expect(joinLineContent(lines[0]!)).toBe('const x = 1 // [!code ++] trailing');
|
||||
});
|
||||
|
||||
it('returns diff: null for lines without a marker', async () => {
|
||||
const lines = await highlightWithInlines(singleLineBlock('javascript', 'console.log("hi")'));
|
||||
expect(lines[0]!.diff).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies and strips marker via plainHighlighting fallback', async () => {
|
||||
const lines = await highlightWithInlines(
|
||||
singleLineBlock(undefined, 'plain text // [!code ++]')
|
||||
);
|
||||
expect(lines[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(lines[0]!)).toBe('plain text');
|
||||
});
|
||||
|
||||
it('preserves inline annotation when marker is stripped', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: { syntax: 'javascript' },
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'console.' }],
|
||||
},
|
||||
{
|
||||
object: 'inline',
|
||||
type: 'annotation',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'log' }],
|
||||
},
|
||||
],
|
||||
isVoid: false,
|
||||
fragments: [],
|
||||
},
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: '("Hi") // [!code ++]' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tokens[0]!.diff).toBe('added');
|
||||
expect(joinLineContent(tokens[0]!)).toBe('console.log("Hi")');
|
||||
// inline annotation around "log" must be preserved
|
||||
const hasAnnotation = tokens[0]!.tokens.some(
|
||||
(t) =>
|
||||
t.type === 'annotation' &&
|
||||
t.children.some((c) => c.type === 'shiki' && c.token.content === 'log')
|
||||
);
|
||||
expect(hasAnnotation).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle \\r', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
object: 'block',
|
||||
|
||||
@@ -34,11 +34,114 @@ export type HighlightTheme = {
|
||||
lines: HighlightLine[];
|
||||
};
|
||||
|
||||
export type LineDiffNotation = 'added' | 'deleted';
|
||||
|
||||
export type HighlightLine = {
|
||||
highlighted: boolean;
|
||||
diff: LineDiffNotation | null;
|
||||
tokens: HighlightToken[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects an in-source diff notation marker at the end of a line, e.g.
|
||||
* `// [!code ++]`, `# [!code --]`, `<!-- [!code ++] -->`, `/* [!code --] */`.
|
||||
* Mirror of the gitbook-x parser; keep regex byte-for-byte identical.
|
||||
*/
|
||||
const NOTATION_PATTERN =
|
||||
/[ \t]*(?:(?:\/\/|#|--|;)\s*\[!code\s+(\+\+|--)\]|<!--\s*\[!code\s+(\+\+|--)\]\s*-->|\/\*\s*\[!code\s+(\+\+|--)\]\s*\*\/)\s*$/;
|
||||
|
||||
export function parseDiffNotation(
|
||||
line: string
|
||||
): { diff: LineDiffNotation; markerStart: number } | null {
|
||||
const match = NOTATION_PATTERN.exec(line);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const variant = match[1] ?? match[2] ?? match[3];
|
||||
return {
|
||||
diff: variant === '++' ? 'added' : 'deleted',
|
||||
markerStart: match.index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a sequence of HighlightTokens so only the first `maxLen` characters
|
||||
* (counted across all tokens, recursing into annotations) remain. Used to strip
|
||||
* trailing diff-notation markers from rendered output.
|
||||
*/
|
||||
export function truncateHighlightTokens(
|
||||
tokens: HighlightToken[],
|
||||
maxLen: number
|
||||
): HighlightToken[] {
|
||||
const out: HighlightToken[] = [];
|
||||
let remaining = maxLen;
|
||||
for (const token of tokens) {
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
const len = highlightTokenLength(token);
|
||||
if (len <= remaining) {
|
||||
out.push(token);
|
||||
remaining -= len;
|
||||
continue;
|
||||
}
|
||||
out.push(sliceHighlightToken(token, remaining));
|
||||
remaining = 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function highlightTokenLength(token: HighlightToken): number {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return token.content.length;
|
||||
case 'shiki':
|
||||
return token.token.content.length;
|
||||
case 'annotation':
|
||||
return token.children.reduce((acc, child) => acc + highlightTokenLength(child), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate the text content of a sequence of HighlightTokens, recursing
|
||||
* into annotation children. Mirror of {@link highlightTokenLength}.
|
||||
*/
|
||||
export function getHighlightTokensText(tokens: HighlightToken[]): string {
|
||||
return tokens
|
||||
.map((token) => {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return token.content;
|
||||
case 'shiki':
|
||||
return token.token.content;
|
||||
case 'annotation':
|
||||
return getHighlightTokensText(token.children);
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function sliceHighlightToken(token: HighlightToken, maxLen: number): HighlightToken {
|
||||
switch (token.type) {
|
||||
case 'plain':
|
||||
return { type: 'plain', content: token.content.slice(0, maxLen) };
|
||||
case 'shiki': {
|
||||
const inner = token.token as ThemedToken & { start?: number; end?: number };
|
||||
const newContent = inner.content.slice(0, maxLen);
|
||||
const newToken: ThemedToken & { start?: number; end?: number } = {
|
||||
...inner,
|
||||
content: newContent,
|
||||
};
|
||||
if (typeof inner.start === 'number') {
|
||||
newToken.end = inner.start + newContent.length;
|
||||
}
|
||||
return { type: 'shiki', token: newToken };
|
||||
}
|
||||
case 'annotation':
|
||||
return { ...token, children: truncateHighlightTokens(token.children, maxLen) };
|
||||
}
|
||||
}
|
||||
|
||||
export type HighlightToken =
|
||||
| { type: 'plain'; content: string }
|
||||
| { type: 'shiki'; token: ThemedToken }
|
||||
@@ -136,6 +239,9 @@ export async function highlight(
|
||||
const lineBlock = block.nodes[index];
|
||||
const result: HighlightToken[] = [];
|
||||
|
||||
const lineText = tokens.map((token) => token.content).join('');
|
||||
const notation = parseDiffNotation(lineText);
|
||||
|
||||
const eatToken = (): PositionedToken | null => {
|
||||
const token = tokens.shift();
|
||||
if (token) {
|
||||
@@ -152,9 +258,14 @@ export async function highlight(
|
||||
|
||||
currentIndex += 1; // for the \n
|
||||
|
||||
const finalTokens = notation
|
||||
? truncateHighlightTokens(result, notation.markerStart)
|
||||
: result;
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock?.data.highlighted),
|
||||
tokens: result,
|
||||
diff: notation?.diff ?? null,
|
||||
tokens: finalTokens,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/a
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { bundledThemesInfo } from 'shiki/themes';
|
||||
import { customThemes } from './customThemes';
|
||||
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
|
||||
import {
|
||||
type HighlightTheme,
|
||||
type HighlightToken,
|
||||
type RenderedInline,
|
||||
getHighlightTokensText,
|
||||
parseDiffNotation,
|
||||
truncateHighlightTokens,
|
||||
} from './highlight';
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
@@ -62,9 +69,14 @@ export function plainHighlight(
|
||||
};
|
||||
});
|
||||
|
||||
// Detect diff notation against the built tokens (not the raw nodes)
|
||||
// so any evaluated inline expressions are included in the offset math.
|
||||
const notation = parseDiffNotation(getHighlightTokensText(tokens));
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock.data.highlighted),
|
||||
tokens,
|
||||
diff: notation?.diff ?? null,
|
||||
tokens: notation ? truncateHighlightTokens(tokens, notation.markerStart) : tokens,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -65,3 +65,40 @@ export function convertCodeStringToBlock(args: {
|
||||
nodes: lines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the code text of a code block from its wrapper id.
|
||||
* Returns null when the block isn't in the DOM (e.g. not yet hydrated).
|
||||
*/
|
||||
export function getCodeTextFromId(codeId: string): string | null {
|
||||
const element = document.getElementById(codeId)?.querySelector('code');
|
||||
return element ? getCodeText(element) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the code text from the DOM,
|
||||
* ignoring the empty white space we use for empty lines (represented with a class "ew").
|
||||
*/
|
||||
export function getCodeText(code: HTMLElement): string {
|
||||
let text = '';
|
||||
|
||||
const iterate = (node: Node) => {
|
||||
if (node instanceof HTMLBRElement) {
|
||||
text += '\n';
|
||||
} else if (node instanceof HTMLSpanElement) {
|
||||
if (node.classList.contains('ew')) {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += node.innerText;
|
||||
}
|
||||
} else if (node instanceof HTMLElement) {
|
||||
node.childNodes.forEach(iterate);
|
||||
} else {
|
||||
text += node.textContent;
|
||||
}
|
||||
};
|
||||
|
||||
iterate(code);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -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')} />;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface DocumentContext {
|
||||
* @default false
|
||||
*/
|
||||
withLinkPreviews?: boolean;
|
||||
|
||||
/**
|
||||
* Optional table row search query.
|
||||
*/
|
||||
tableSearchQuery?: string;
|
||||
}
|
||||
|
||||
export interface DocumentContextProps {
|
||||
@@ -93,7 +98,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,
|
||||
]}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script' });
|
||||
ReactDOM.preload('https://iframely.net/embed.js', { as: 'script' });
|
||||
|
||||
const embed = await getDataOrNull(
|
||||
context.contentContext.dataFetcher.getEmbedByUrl({
|
||||
@@ -41,7 +41,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
}}
|
||||
data-visual-test="blackout"
|
||||
/>
|
||||
<Script strategy="lazyOnload" src="https://cdn.iframe.ly/embed.js" />
|
||||
<Script strategy="lazyOnload" src="https://iframely.net/embed.js" />
|
||||
</>
|
||||
) : embed.type === 'integration' ? (
|
||||
<IntegrationBlock
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user