Compare commits

..

79 Commits

Author SHA1 Message Date
Samy Pessé 46fa9d954c Version Packages (#3720)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-13 08:42:09 +02:00
Greg Bergé 5e1ed5358e Add npm token to publish with bun (#3721) 2025-10-13 11:38:57 +05:30
Greg Bergé 295f03db49 Force republishing packages (#3719) 2025-10-11 07:36:58 +00:00
Claire Chabas bdde3929fc Fix web frame height (#3709) 2025-10-10 17:41:54 +02:00
conico974 4f4210d24a Update @opennextjs/cloudflare to version 1.9.2 (#3710)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-10-10 17:20:11 +02:00
Greg Bergé c038ec8f81 Fix package publishing (#3718) 2025-10-10 13:39:39 +00:00
Samy Pessé 75b52eac28 Version Packages (#3651)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-10 15:11:33 +02:00
Greg Bergé ad4d07dc06 Support inline icon colors (#3717) 2025-10-10 14:51:40 +02:00
Greg Bergé 75086740f8 Fix bundling of embed package (#3716) 2025-10-10 10:57:06 +02:00
Nolann B. eea8f1e00f Enhance OpenAPI security scopes handling (#3712) 2025-10-09 10:16:12 +02:00
Greg Bergé 4f9abfb6f2 Display headings from reusable content in TOC (#3708) 2025-10-08 12:17:05 +02:00
conico974 252afceab0 Store customization in a cookie for preview route (#3707)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-10-08 10:37:52 +02:00
Viktor Renkema bef191660a Allow hiding toolbar for one session or persistent (#3698) 2025-10-08 09:48:38 +02:00
Zeno Kapitein 145625142d Unify section display condition (#3706) 2025-10-07 15:12:08 +02:00
Greg Bergé f70a2aa310 Disable flashing when navigating between sections (#3705) 2025-10-07 12:00:42 +02:00
Samy Pessé 56e46ce8ab Add Italian translation (#3704) 2025-10-06 17:07:30 +02:00
Nolann B. 754cc11e0b Fix merge logic for allOf alternatives (#3515) 2025-10-06 14:37:51 +02:00
Nolann B. 3551d147e9 Move to urls for page actions (#3703) 2025-10-06 09:42:37 +02:00
Zeno Kapitein 162cfe4163 Hide sections if only one is present (#3701) 2025-10-04 20:16:02 +02:00
Nolann B. 6815dd197e Fix markdownPageURL for PageActions (#3702) 2025-10-04 19:02:30 +02:00
Zeno Kapitein d74f8a7098 Improve wide page width (#3692) 2025-10-03 18:52:05 +02:00
Zeno Kapitein 1e53f46521 Fix button content truncate (#3693) 2025-10-03 18:51:32 +02:00
Claire Chabas 1a6216df4f Fix URL used for Ask to LLM (#3697) 2025-10-03 14:24:24 +02:00
Nolann B. a629900324 Add dev script for @gitbook/expr (#3700) 2025-10-03 13:23:15 +02:00
Nolann B. a4c33997fe Fix OpenAPI alternatives not showing (#3699) 2025-10-03 13:06:10 +02:00
Brett Jephson badf3a92f6 RND-8165: make sure we get alt text coming through on InlineImage (#3696) 2025-10-03 09:40:34 +01:00
Claire Chabas e0fb9ac3ca Support image frame (#3695) 2025-10-02 10:17:48 +02:00
spastorelli f3e40410c4 Adapt OpenAPI code samples to prefill API key using visitor data (#3666) 2025-09-30 11:09:05 +02:00
spastorelli 8e99871004 Highlight discriminator properties in oneOf, allOf, anyOf objects (#3691) 2025-09-29 17:09:21 +02:00
Zeno Kapitein 1e4e54e752 Fix rounded outline items (#3694) 2025-09-29 13:33:40 +02:00
spastorelli 50c1be3447 Move inline expression evaluation from API to GBO (#3687) 2025-09-26 10:55:09 +02:00
spastorelli 319a1e536c Ensure operation security reqs inherits from spec-level when not defined (#3690) 2025-09-26 09:11:52 +02:00
conico974 50cb0c6e15 Fix NavigationLoader on search and cmd/click (#3689)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-26 08:34:41 +02:00
Zeno Kapitein ceb32b1a2e Redo search scopes (#3688)
Co-authored-by: Claire Chabas <claire.chabas@gmail.com>
2025-09-25 16:49:53 +00:00
Zeno Kapitein f8c54b4d34 Improve assistant demo (#3686) 2025-09-25 17:07:07 +02:00
Greg Bergé c06ad70f8a Improve fonts loading (#3682) 2025-09-25 13:17:21 +02:00
Brett Jephson 0ef5dc8538 Update column width sizing (#3676) 2025-09-25 08:14:15 +01:00
Zeno Kapitein 28adc9b5f1 Add graphic to embed assistant demo page (#3685) 2025-09-24 14:37:41 +00:00
Zeno Kapitein 912d1693f5 Fix section group alignment (#3684) 2025-09-24 13:58:34 +00:00
Samy Pessé c23d4ef331 Fix crash for card cover defined without objectFit (#3683) 2025-09-24 11:01:06 +02:00
Zeno Kapitein c21693fe50 Improve screen reader accessibility for hints (#3680) 2025-09-24 10:23:34 +02:00
Zeno Kapitein c06b3dd60d Improve default site icon (#3679) 2025-09-24 10:22:49 +02:00
conico974 f173e014db Use cloudflare regional cache instead of our own implementation. (#3673)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-24 10:11:11 +02:00
conico974 1b458c436e Fix NavigationLoader not visible with bold header (#3681)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-24 09:54:57 +02:00
conico974 b42b0b3910 Add navigation loader and rename context provider (#3642)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-09-23 15:00:27 +02:00
Brett Jephson 2fc21272ff RND-7985: accessible search (#3637) 2025-09-23 11:36:50 +01:00
Zeno Kapitein 379d486488 Expose "Best match" when site has translations (#3670) 2025-09-22 22:31:17 +02:00
Zeno Kapitein cba583a31c Use siteSpace title instead of space title in search (#3677) 2025-09-22 18:03:50 +02:00
Zeno Kapitein 7a504b7cc5 Fix OpenAPI response dropdown & vertical overflow (#3678) 2025-09-22 18:03:43 +02:00
Addison 8ec5fd0986 Add blob: to style source for integrations (#3675) 2025-09-22 18:41:16 +09:00
spastorelli 529f940a45 Fix OpenAPISecurities and code sample not using operation security requirements (#3671) 2025-09-22 10:00:46 +02:00
Viktor Renkema 8a3c15911f Update tooltip labels in docs sites toolbars (#3674) 2025-09-22 09:28:42 +02:00
spastorelli d7948e34b2 Fix OpenAPI response showing as JSON instead of YAML (#3669) 2025-09-18 18:40:24 +02:00
conico974 9f42211993 Enhance tracking with geolocation headers and continent info in analytics proxy (#3667)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 18:31:21 +02:00
conico974 65e62307bf Update @opennextjs/cloudflare to version 1.8.3 (#3668)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 18:04:38 +02:00
spastorelli 469b332da2 Add support for inline expressions rendering with visitor data on GBO side (#3660) 2025-09-18 16:30:52 +02:00
Zeno Kapitein e434442f1e Support site section groups (#3664) 2025-09-18 15:28:39 +02:00
Samy Pessé 9344431bfc Fix API URL we proxy insights events to (#3665) 2025-09-18 13:14:25 +00:00
conico974 622ecbec1d Handle broken links in header and footer (#3663)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 11:22:57 +02:00
conico974 fa1382b289 Add responsive image sizes for RecordCard component (#3654)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-18 08:55:11 +02:00
Zeno Kapitein b8388e5a2a Add ScrollContainer component, use for section tabs (#3661) 2025-09-17 22:20:32 +02:00
Zeno Kapitein 2bce12798f Fix section group dropdown offset on big screens (#3662) 2025-09-17 19:10:52 +02:00
Greg Bergé f89e889d18 Convert relative URL to absolute in Markdown (page and copy) (#3659) 2025-09-17 18:12:01 +02:00
Zeno Kapitein 2b8a2d24b6 Add stable scroll gutter to search container (#3656) 2025-09-17 17:27:47 +02:00
Zeno Kapitein b85eccdff5 Refactor site section tabs (#3655) 2025-09-17 17:27:34 +02:00
Viktor Renkema d924e655c0 Add visibility label and smoothen magnification (#3657) 2025-09-16 16:34:21 +02:00
Zeno Kapitein b932e4e2e9 Scope search across sections and variants (#3640)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 12:07:29 +02:00
Samy Pessé ca4a3e8c9a Track utm on links from the toolbar (#3653) 2025-09-15 16:51:31 +02:00
John Cashman b498521196 Refactor robots.txt generation logic (#3621)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 14:37:02 +02:00
Samy Pessé 8a8cf2025d Skip using __session cookie if it's in the old format (#3652) 2025-09-15 12:10:51 +02:00
Samy Pessé ed7d47d9b8 Toolbar for authenticated GitBook users (#3650)
Co-authored-by: Viktor Renkema <vrenkema@gmail.com>
2025-09-15 10:32:23 +02:00
Viktor Renkema b5494cc04b Restyle the toolbar in CR and revision preview links (#3631)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-09-15 08:33:23 +02:00
Samy Pessé 229f2ba9d5 Version Packages (#3646)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 16:40:28 +02:00
spastorelli 6da36558f6 Fix exports in gitbook/expr package.json after removing src (#3648) 2025-09-12 16:20:29 +02:00
Samy Pessé ca71c1fad6 Fix MCP in production (#3647) 2025-09-12 14:59:47 +02:00
Samy Pessé 262afa357b Implement an MCP server on each docs site (#3641) 2025-09-12 13:19:16 +02:00
Samy Pessé 7375d3c597 Version Packages (#3645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 11:37:22 +02:00
spastorelli 3548fa6dff Fix eval estree expr cjs named import when gitbook/expr is imported in playwright tests (#3643) 2025-09-12 11:20:33 +02:00
conico974 cb73040e0f Bump Next.js to version 15.4.0 (#3644)
Co-authored-by: Nicolas Dorseuil <nicolas@gitbook.io>
2025-09-12 11:17:43 +02:00
325 changed files with 4235 additions and 9065 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Update button sizing
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix embed tab button size regression
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook": patch
"@gitbook/icons": patch
---
Switch icon masking method to fix visual cutoffs in FA7
+4 -3
View File
@@ -54,9 +54,10 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally #### 3. Install dependencies and run the project locally
##### Prerequisites: ##### Prerequisites:
- Node.js (Version: >=20.6)
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`) - Use `nvm` for easy Node management
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`) - [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15
##### Setup steps: ##### Setup steps:
@@ -30,7 +30,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -49,7 +49,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -61,7 +61,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -71,7 +71,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -70,7 +70,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
@@ -81,7 +81,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -104,7 +104,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.43.0' wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+2 -3
View File
@@ -4,9 +4,8 @@ on:
push: push:
branches: branches:
- main - main
- changeset-release/main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -42,7 +41,7 @@ jobs:
name: Build (Open Source) name: Build (Open Source)
timeout-minutes: 6 timeout-minutes: 6
env: env:
BUN_NPM_TOKEN: '' NPM_TOKEN_READONLY: ''
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1 -1
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (preview) name: Deploy v2 to Vercel (preview)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (production) name: Deploy v2 to Vercel (production)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (staging) name: Deploy v2 to Vercel (staging)
+8 -16
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -14,38 +14,30 @@ jobs:
publish: publish:
name: Publish name: Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
pull-requests: write
contents: write
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0 fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
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
- name: Setup Bun - name: Setup Bun
uses: ./.github/composite/setup-bun uses: ./.github/composite/setup-bun
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- name: Create Release Pull Request - name: Create Release Pull Request or Publish to npm
id: changesets id: changesets
uses: changesets/action@v1 uses: changesets/action@v1
with: with:
publish: bun run publish-all-packages publish: npm run release
version: bun run changeset-version version: npm run changeset-version
env: env:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created # 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 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-4
View File
@@ -25,7 +25,3 @@ yarn-error.log*
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
.npmrc
# Bun pack artifacts
packages/*/*.tgz
+1 -1
View File
@@ -1 +1 @@
v22.3 v20.6
+2 -2
View File
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites ### Prerequisites
- Node.js (Version: >= 22.3) - Node.js (Version: >=20.6)
- Use nvm for easy Node management - Use nvm for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15 - We use a text-based lockfile which isn't supported below 1.2.15
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
``` ```
.env.local .env.local
BUN_NPM_TOKEN=xxx NPM_TOKEN_READONLY=xxx
``` ```
and then reinstall dependencies. and then reinstall dependencies.
+891 -628
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes] [install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" } "gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+9 -25
View File
@@ -1,22 +1,17 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.1.0", "version": "0.1.0",
"engines": {
"node": "^22.3.0"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8", "@changesets/cli": "^2.29.7",
"turbo": "^2.6.3", "turbo": "^2.5.8",
"vercel": "^39.4.2" "vercel": "^39.4.2"
}, },
"packageManager": "bun@1.3.2", "packageManager": "bun@1.2.15",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@types/react": "catalog:", "react": "^19.0.0",
"@types/react-dom": "catalog:", "react-dom": "^19.0.0",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2" "esbuild": "0.24.2"
}, },
"private": true, "private": true,
@@ -32,28 +27,17 @@
"e2e-customers": "turbo run e2e-customers", "e2e-customers": "turbo run e2e-customers",
"changeset": "changeset", "changeset": "changeset",
"changeset-version": "changeset version && bun run format && bun update", "changeset-version": "changeset version && bun run format && bun update",
"publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful", "release": "turbo run release && bun run publish-all-packages",
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean" "clean": "turbo run clean"
}, },
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@tsconfig/strictest": "^2.0.6", "@gitbook/api": "0.143.2",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.155.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"assert-never": "^1.4.0",
"bidc": "^0.0.2", "bidc": "^0.0.2",
"bun-types": "^1.1.20", "tsdown": "^0.15.6"
"classnames": "^2.5.1",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
-16
View File
@@ -1,21 +1,5 @@
# @gitbook/browser-types # @gitbook/browser-types
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+4 -12
View File
@@ -8,29 +8,21 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.1.1",
"version": "0.1.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:" "@gitbook/icons": "workspace:"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "catalog:", "typescript": "^5.5.3"
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/cache-tags # @gitbook/cache-tags
## 0.3.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.3.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.3.2 ## 0.3.2
### Patch Changes ### Patch Changes
+7 -13
View File
@@ -4,32 +4,26 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.3.2",
"version": "0.3.4",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"assert-never": "catalog:" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "catalog:", "typescript": "^5.5.3"
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src", "dev": "tsc -w"
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "src", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/colors # @gitbook/colors
## 0.4.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.4.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.4.1 ## 0.4.1
### Patch Changes ### Patch Changes
+6 -12
View File
@@ -4,28 +4,22 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.4.1",
"version": "0.4.3",
"devDependencies": { "devDependencies": {
"bun-types": "catalog:", "typescript": "^5.5.3"
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src", "dev": "tsc -w"
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "src", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
-27
View File
@@ -1,32 +1,5 @@
# @gitbook/embed # @gitbook/embed
## 0.2.0
### Minor Changes
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
### Patch Changes
- Updated dependencies [62438f4]
- @gitbook/icons@0.4.0
## 0.1.5
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.4
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.3 ## 0.1.3
### Patch Changes ### Patch Changes
+8 -370
View File
@@ -1,61 +1,24 @@
# GitBook Docs Embed (`@gitbook/embed`) # `@gitbook/embed`
Embed your GitBook docs in your product or website. Embed the GitBook Docs Assistant in your product or website.
The Docs Embed can contain two tabs:
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
- **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.
# Usage # Usage
## As a standalone script from your docs site ## As a script from your docs site
All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website. All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
The script is served at `https://docs.company.com/~gitbook/embed/script.js`. The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname. You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
```html ```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script> <script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script> <script>
// Initialize with Authenticated Access (optional)
window.GitBook('init',
{ siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } }
);
window.GitBook('show'); window.GitBook('show');
</script> </script>
``` ```
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
### Example: Configuring the widget
```javascript
GitBook('configure', {
button: {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
```
See the [Configuration](#configuration) section for all available options.
## As a package from NPM ## As a package from NPM
Install the package: `npm install @gitbook/embed` and import it in your web application: Install the package: `npm install @gitbook/embed` and import it in your web application:
@@ -67,46 +30,10 @@ const gitbook = createGitBook({
siteURL: 'https://docs.company.com' siteURL: 'https://docs.company.com'
}); });
// Create an iframe and get its URL
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({ iframe.src = gitbook.getFrameURL();
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
userId: '123',
plan: 'premium'
}
}
});
// Create a frame client to communicate with the iframe
const frame = gitbook.createFrame(iframe); const frame = gitbook.createFrame(iframe);
// Use the frame client methods
frame.navigateToPage('/getting-started'); // Navigate to a page in the docs tab
frame.navigateToAssistant(); // Switch to the assistant tab
frame.postUserMessage('How do I get started?');
frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
// Listen to events
frame.on('close', () => {
console.log('Frame closed');
});
``` ```
## As React components ## As React components
@@ -114,298 +41,9 @@ frame.on('close', () => {
After installing the NPM package, you can import prebuilt React components: After installing the NPM package, you can import prebuilt React components:
```tsx ```tsx
import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react'; import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com"> <GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame <GitBookAssistantFrame />
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
]}
tools={[/* ... */]}
/>
</GitBookProvider> </GitBookProvider>
``` ```
You can also use the `useGitBook` hook to access the client:
```tsx
import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
// ...
}
```
# API Reference
## Method Comparison
| Method | Standalone Script | NPM Package | React Components |
|--------|------------------|-------------|------------------|
| **Initialize** | `GitBook('init', options, frameOptions)` | `createGitBook(options)` | `<GitBookProvider siteURL="...">` |
| **Get frame URL** | ❌ (handled internally) | `client.getFrameURL(options)` | `useGitBook().getFrameURL(options)` |
| **Create frame client** | ❌ (handled internally) | `client.createFrame(iframe)` | `useGitBook().createFrame(iframe)` |
| **Show/Hide widget** | `GitBook('show')` / `GitBook('hide')` | ❌ | ❌ |
| **Open/Close window** | `GitBook('open')` / `GitBook('close')` / `GitBook('toggle')` | ❌ | ❌ |
| **Navigate to page** | `GitBook('navigateToPage', path)` | `frame.navigateToPage(path)` | Via frame client |
| **Navigate to assistant** | `GitBook('navigateToAssistant')` | `frame.navigateToAssistant()` | Via frame client |
| **Post message** | `GitBook('postUserMessage', message)` | `frame.postUserMessage(message)` | Via frame client |
| **Clear chat** | `GitBook('clearChat')` | `frame.clearChat()` | Via frame client |
| **Configure** | `GitBook('configure', settings)` | `frame.configure(settings)` | Props on `<GitBookFrame>` |
| **Event listeners** | ❌ | `frame.on(event, listener)` | Via frame client |
| **Unload** | `GitBook('unload')` | ❌ | ❌ |
## Method Signatures
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
- `GitBook('close')` - Close widget window
- `GitBook('toggle')` - Toggle widget window
- `GitBook('navigateToPage', path: string)` - Navigate to page
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
- `GitBook('postUserMessage', message: string)` - Post message to chat
- `GitBook('clearChat')` - Clear chat history
- `GitBook('configure', settings: {...})` - Configure widget
- `GitBook('unload')` - Unload widget
### NPM Package
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
- `frame.navigateToPage(path: string)``void`
- `frame.navigateToAssistant()``void`
- `frame.postUserMessage(message: string)``void`
- `frame.clearChat()``void`
- `frame.configure(settings: Partial<GitBookEmbeddableConfiguration>)``void`
- `frame.on(event: string, listener: Function)``() => void` (unsubscribe)
### React Components
**Components:**
- `<GitBookProvider siteURL: string>` - Provider component
- `<GitBookFrame {...props}>` - Frame component (accepts all config options as props)
**Hooks:**
- `useGitBook()``GitBookClient` (must be used within `<GitBookProvider>`)
# Configuration
Configuration options are available across usage methods as follows:
- **Standalone script**: via `GitBook('configure', {...})`
- **NPM package**: via `frame.configure({...})`
- **React components**: via props on `<GitBookFrame>`
### `tabs`
Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'docs']
```
### `actions`
Available in: Standalone script, NPM package, React components
Custom action buttons rendered in the sidebar alongside tabs. Each action button triggers a callback when clicked.
**Note**: This prop was previously named `buttons`. Use `actions` instead, it has the same functionality.
- **Type**: `GitBookEmbeddableActionDefinition[]`
- **Properties**:
- `icon`: `string` - Icon name. Any [FontAwesome icon](https://fontawesome.com/search) is supported. (e.g., `'rocket'`, `'comments'`, `'user-circle'`, ...)
- `label`: `string` - Button label text
- `onClick`: `() => void | Promise<void>` - Callback function when clicked
```javascript
actions: [
{
icon: 'comments',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
},
{
icon: 'rocket',
label: 'Get started',
onClick: () => {
GitBook('navigateToPage', '/getting-started');
}
}
]
```
### `greeting`
Available in: Standalone script, NPM package, React components
Welcome message displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) tab.
- **Type**: `{ title: string, subtitle: string }`
```javascript
greeting: {
title: 'Welcome!',
subtitle: 'How can I help you today?'
}
```
### `suggestions`
Available in: Standalone script, NPM package, React components
Suggested questions displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) welcome screen.
- **Type**: `string[]`
```javascript
suggestions: [
'What is GitBook?',
'How do I get started?',
'What can you do?'
]
```
### `tools`
Available in: Standalone script, NPM package, React components
Custom AI tools to extend the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant). Tools allow the assistant to execute functions and integrate with your own systems.
**Note**: In addition to custom tools, the Assistant will always have access to any [MCP servers you define](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant#extend-gitbook-assistant-with-mcp-servers) in your site's AI settings.
- **Type**: `GitBookToolDefinition[]`
- **Properties**:
- `name`: `string` - Unique tool identifier
- `description`: `string` - Description of what the tool does (used by the AI to decide when and how to use it).
- `inputSchema`: `object` - JSON schema defining the tool's input parameters
- `execute`: `(input: object) => Promise<{ output: any, summary: string }>` - Async function that executes the tool.
- `output`: The result of the tool execution, provided to the AI to continue working with. Not shown to the user.
- `summary`: The visual summary of the tool execution, shown in the user's chat window.
- `confirmation`: `{ icon?: string, label: string }` (optional) - Confirmation button shown before execution, useful for actions that require the user's express approval.
```javascript
tools: [
{
name: 'get_user_info',
description: 'Get information about the current user',
inputSchema: {
type: 'object',
properties: {
userId: {
type: 'string',
description: 'The user ID to look up'
}
},
required: ['userId']
},
execute: async (input) => {
const user = await fetch(`/api/users/${input.userId}`).then(r => r.json());
return {
output: { name: user.name, plan: user.plan },
summary: `Retrieved info for user ${user.name}`
};
}
},
{
name: 'create_ticket',
description: 'Create a support ticket',
confirmation: {
icon: 'circle-question',
label: 'Create support ticket?'
},
inputSchema: {
type: 'object',
properties: {
subject: { type: 'string' },
description: { type: 'string' }
},
required: ['subject', 'description']
},
execute: async (input) => {
const ticket = await fetch('/api/tickets', {
method: 'POST',
body: JSON.stringify(input)
}).then(r => r.json());
return {
output: { ticketId: ticket.id },
summary: `Created ticket #${ticket.id}`
};
}
}
]
```
### `visitor` (Authenticated Access)
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
[Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access) options passed when creating the frame URL. Used for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) and [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access).
**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({ visitor: {...} })`
**React components**: Pass as the `visitor` prop on `<GitBookFrame>`
- **Type**: `{ token?: string, unsignedClaims?: Record<string, unknown> }`
- **Properties**:
- `token`: `string` (optional) - Signed JWT token for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) or [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access)
- `unsignedClaims`: `Record<string, unknown>` (optional) - Unsigned claims that can be used in dynamic expressions via `visitor.claims.unsigned.<claim-name>`
```javascript
visitor: {
token: 'your-jwt-token',
unsignedClaims: {
userId: '123',
plan: 'premium',
role: 'admin'
}
}
```
### `button`
Available in: Standalone script only
Configure the widget button for the standalone script. This option is not available when using the NPM package or React components, since they can be customized completely.
- **Type**: `{ label: string, icon: 'assistant' | 'sparkle' | 'help' | 'book' }`
- **Properties**:
- `label`: `string` - Button label text
- `icon`: `'assistant' | 'sparkle' | 'help' | 'book'` - Icon displayed on the button. Choose from one of 4 presets.
```javascript
button: {
label: 'Ask',
icon: 'assistant'
}
```
+5 -11
View File
@@ -12,21 +12,19 @@
"default": "./dist/react/index.js" "default": "./dist/react/index.js"
} }
}, },
"sideEffects": false, "version": "0.1.3",
"version": "0.2.0",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:", "@gitbook/icons": "workspace:",
"bidc": "catalog:" "bidc": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"react": "*" "react": "^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "catalog:", "typescript": "^5.5.3",
"react": "catalog:" "react": "^19.0.0"
}, },
"scripts": { "scripts": {
"build": "bun run build-lib && bun run build-standalone", "build": "bun run build-lib && bun run build-standalone",
@@ -34,15 +32,11 @@
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone", "build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src", "dev": "bun run build -- --watch ./src"
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export function createGitBook(options: CreateGitBookOptions) {
const client: GitBookClient = { const client: GitBookClient = {
getFrameURL: (frameOptions) => { getFrameURL: (frameOptions) => {
const url = new URL(options.siteURL); const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`; url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
if (frameOptions.visitor?.token) { if (frameOptions.visitor?.token) {
url.searchParams.set('token', frameOptions.visitor.token); url.searchParams.set('token', frameOptions.visitor.token);
@@ -64,9 +64,8 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const events = new Map<string, Array<(...args: any[]) => void>>(); const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = { const configuration: GitBookEmbeddableConfiguration = {
tabs: ['assistant', 'docs'], buttons: [],
actions: [], welcomeMessage: '',
greeting: { title: '', subtitle: '' },
suggestions: [], suggestions: [],
tools: [], tools: [],
}; };
+5 -15
View File
@@ -23,7 +23,7 @@ export type GitBookToolDefinition = AIToolDefinition & {
/** /**
* Custom button definition to be passed to the embeddable GitBook. * Custom button definition to be passed to the embeddable GitBook.
*/ */
export type GitBookEmbeddableActionDefinition = { export type GitBookEmbeddableButtonDefinition = {
/** /**
* Icon to be displayed in the button. * Icon to be displayed in the button.
*/ */
@@ -41,26 +41,16 @@ export type GitBookEmbeddableActionDefinition = {
}; };
/** /**
* Overall configuration for the layout of the GitBook embed. * Overall configuration for the layout of the embeddable GitBook.
*/ */
export type GitBookEmbeddableConfiguration = { export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
/** /**
* Additional buttons to be displayed in the header of the GitBook embed. * Buttons to be displayed in the header of the embeddable GitBook.
* @deprecated Use `actions` instead.
*/ */
buttons?: GitBookEmbeddableActionDefinition[]; buttons: GitBookEmbeddableButtonDefinition[];
/** Message to be displayed in the welcome page. */ /** Message to be displayed in the welcome page. */
greeting: { welcomeMessage: string;
title: string;
subtitle: string;
};
/** Suggestions of questions to be displayed in the welcome page. */ /** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[]; suggestions: string[];
+10 -13
View File
@@ -1,6 +1,4 @@
'use client'; import React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
GetFrameURLOptions, GetFrameURLOptions,
GitBookEmbeddableConfiguration, GitBookEmbeddableConfiguration,
@@ -17,29 +15,28 @@ export type GitBookFrameProps = {
* Render a frame with the GitBook Assistant in it. * Render a frame with the GitBook Assistant in it.
*/ */
export function GitBookFrame(props: GitBookFrameProps) { export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, actions, greeting, suggestions, tools } = props; const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null); const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook(); const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null); const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]); const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
useEffect(() => { React.useEffect(() => {
if (frameRef.current) { if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current)); setGitbookFrame(gitbook.createFrame(frameRef.current));
} }
}, [gitbook]); }, [gitbook]);
useEffect(() => { React.useEffect(() => {
gitbookFrame?.configure({ gitbookFrame?.configure({
tabs: ['assistant', 'docs'], buttons,
actions, welcomeMessage,
greeting,
suggestions, suggestions,
tools, tools,
}); });
}, [gitbookFrame, actions, greeting, suggestions, tools]); }, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
return ( return (
<iframe <iframe
+28 -47
View File
@@ -31,50 +31,24 @@ type StandaloneCalls =
// Clear the chat // Clear the chat
| ['clearChat'] | ['clearChat']
// Configure the embed // Configure the embed
| ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>] | ['configure', Partial<GitBookEmbeddableConfiguration>]
// Navigate to a page // Navigate to a page
| ['navigateToPage', string] | ['navigateToPage', string]
// Navigate to the assistant // Navigate to the assistant
| ['navigateToAssistant']; | ['navigateToAssistant'];
type StandaloneConfiguration = {
/** Configure the button to open the embed */
button: {
/** Label to be displayed in the button. */
label: string;
/** Icon to be displayed in the button. */
icon: 'assistant' | 'sparkle' | 'help' | 'book';
};
};
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & { export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
q?: StandaloneCalls[]; q?: StandaloneCalls[];
}; };
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
icon: 'assistant',
},
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
};
const widgetButton = document.createElement('button'); const widgetButton = document.createElement('button');
widgetButton.id = 'gitbook-widget-button'; widgetButton.id = 'gitbook-widget-button';
widgetButton.addEventListener('click', () => { widgetButton.addEventListener('click', () => {
GitBook('toggle'); GitBook('toggle');
}); });
widgetButton.innerHTML = ` widgetButton.innerHTML = `
<span id="gitbook-widget-button-icon" data-icon="${frameConfiguration.button.icon}"></span> <span id="gitbook-widget-button-icon"></span>
<span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span> <span id="gitbook-widget-button-label">Ask</span>
`; `;
const widgetWindow = document.createElement('div'); const widgetWindow = document.createElement('div');
@@ -84,6 +58,17 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton); document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow); document.body.appendChild(widgetWindow);
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
function getClient() { function getClient() {
if (!_client) { if (!_client) {
throw new Error( throw new Error(
@@ -150,31 +135,27 @@ const GitBook = (...args: StandaloneCalls) => {
case 'postUserMessage': case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]); getIframe().frame.postUserMessage(args[1]);
break; break;
case 'configure': { case 'configure':
const settings = args[1];
frameConfiguration = { frameConfiguration = {
...frameConfiguration, ...frameConfiguration,
...settings, ...args[1],
}; };
// Update the button label and icon
if (settings.button?.label) {
const label = widgetButton.querySelector('#gitbook-widget-button-label');
if (label) {
label.textContent = settings.button.label;
}
}
if (settings.button?.icon) {
const icon = widgetButton.querySelector('#gitbook-widget-button-icon');
if (icon) {
icon.setAttribute('data-icon', settings.button.icon);
}
}
getIframe().frame.configure({ getIframe().frame.configure({
...frameConfiguration, ...frameConfiguration,
buttons: [
...frameConfiguration.buttons,
// Always include a close button
{
icon: 'close',
label: 'Close',
onClick: () => {
GitBook('close');
},
},
],
}); });
break; break;
}
case 'clearChat': case 'clearChat':
getIframe().frame.clearChat(); getIframe().frame.clearChat();
break; break;
+1 -24
View File
@@ -29,16 +29,6 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); --gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -113,18 +103,6 @@
background-color: currentColor; background-color: currentColor;
} }
#gitbook-widget-button-icon[data-icon="sparkle"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/sparkle.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="help"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/circle-question.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="book"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/book-open.svg?v=2&token=a463935e93");
}
#gitbook-widget-button.open #gitbook-widget-button-icon { #gitbook-widget-button.open #gitbook-widget-button-icon {
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93'); mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
} }
@@ -142,8 +120,7 @@
z-index: 9998; z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left)))); width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top)))); height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-translucent); background-color: var(--gitbook-widget-background-solid);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color); border: 1px solid var(--gitbook-widget-border-color);
border-radius: var(--gitbook-widget-radius); border-radius: var(--gitbook-widget-radius);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+3 -7
View File
@@ -2,12 +2,8 @@ import { defineConfig } from 'tsdown';
export default defineConfig([ export default defineConfig([
{ {
entry: 'src/index.ts', entry: ['src/index.ts', 'src/react/index.ts'],
outDir: 'dist', dts: true,
}, format: ['esm'],
{
entry: 'src/react/index.ts',
outDir: 'dist/react',
unbundle: true,
}, },
]); ]);
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/emoji-codepoints # @gitbook/emoji-codepoints
## 0.2.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.2.1 ## 0.2.1
### Patch Changes ### Patch Changes
+7 -3
View File
@@ -1,15 +1,19 @@
{ {
"name": "@gitbook/emoji-codepoints", "name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints", "description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.2", "version": "0.2.1",
"private": true, "private": true,
"exports": "./dist/index.ts", "exports": "./dist/index.ts",
"sideEffects": false, "dependencies": {},
"devDependencies": { "devDependencies": {
"emoji-assets": "^9.0.0" "emoji-assets": "^8.0.0"
}, },
"scripts": { "scripts": {
"generate": "bun ./build.ts", "generate": "bun ./build.ts",
"clean": "rm -rf ./dist" "clean": "rm -rf ./dist"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
} }
} }
-13
View File
@@ -1,18 +1,5 @@
# @gitbook/expr # @gitbook/expr
## 1.2.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- 10995e0: Use NPM dependency for eval-estree-expression
## 1.2.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 1.2.2 ## 1.2.2
### Patch Changes ### Patch Changes
+10 -15
View File
@@ -1,47 +1,42 @@
{ {
"name": "@gitbook/expr", "name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.", "description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.4", "version": "1.2.2",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
"acorn": "^8.14.0", "acorn": "^8.14.0",
"acorn-loose": "8.4.0", "acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4", "acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0", "escodegen": "^2.1.0",
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/strictest": "catalog:", "bun-types": "^1.1.20",
"@tsconfig/node20": "catalog:",
"@types/escodegen": "^0.0.10",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"bun-types": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "catalog:" "@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15",
"@types/escodegen": "^0.0.10"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"unit": "bun test", "unit": "bun test",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"dev": "bun run build -- --watch ./src", "dev": "bun run build -- --watch ./src"
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
@@ -2,7 +2,8 @@ import { defineConfig } from 'tsdown';
export default defineConfig([ export default defineConfig([
{ {
entry: 'src/index.ts', entry: ['src/index.ts'],
unbundle: true, dts: true,
format: ['esm'],
}, },
]); ]);
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/fonts # @gitbook/fonts
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+8 -14
View File
@@ -4,38 +4,32 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.1.1",
"version": "0.1.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:" "@gitbook/api": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "catalog:", "google-font-metadata": "^6.0.3",
"google-font-metadata": "^6.0.7", "typescript": "^5.5.3"
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"generate": "bun ./bin/generate.js", "generate": "bun ./bin/generate.js",
"build": "tsdown", "build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src", "dev": "tsc -w",
"clean": "rm -rf ./dist && rm -rf ./src/data", "clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test", "unit": "bun test"
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "bin", "README.md", "CHANGELOG.md"], "files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
@@ -1,57 +1,57 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots // Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`getDefaultFont should return correct object for Latin text 1`] = ` exports[`getDefaultFont should return correct object for Latin text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = ` exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Greek text 1`] = ` exports[`getDefaultFont should return correct object for Greek text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle mixed script text 1`] = ` exports[`getDefaultFont should handle mixed script text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: regular 1`] = ` exports[`getDefaultFont should handle different font weights: regular 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: bold 1`] = ` exports[`getDefaultFont should handle different font weights: bold 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: inter 1`] = ` exports[`getDefaultFont should handle different fonts: inter 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: roboto 1`] = ` exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{ {
"font": "Roboto", "font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff", "url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
} }
`; `;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
-116
View File
@@ -1,121 +1,5 @@
# gitbook # gitbook
## 0.21.1
### Patch Changes
- Updated dependencies [e7fb771]
- @gitbook/icons@0.4.1
- @gitbook/react-contentkit@0.7.10
## 0.21.0
### Minor Changes
- 3553db0: Add a RSS feed to all pages with update blocks.
### Patch Changes
- f478ddc: Add Input component
- 3817289: Fix overflowing section groups
- 86e7aa6: Fix search results cursor
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
- ed9e8fe: Add support for button actions
- 9c69cc4: Add customizable suggested questions
- c51076e: Fix links to other spaces and root page in embeddable view.
- 6278a18: Support `greeting` and fix suggested questions if there are no custom ones defined
- 04a4175: Remove hidden site spaces from navigation
- 74f8931: Fix hidden section not found
- 344842f: Improve OpenAPI circular references
- Updated dependencies [4766092]
- Updated dependencies [8761cee]
- Updated dependencies [461e15f]
- Updated dependencies [1430cee]
- Updated dependencies [62438f4]
- Updated dependencies [3e40b4d]
- Updated dependencies [87d68ea]
- Updated dependencies [344842f]
- @gitbook/react-openapi@1.5.4
- @gitbook/openapi-parser@3.0.6
- @gitbook/embed@0.2.0
- @gitbook/icons@0.4.0
- @gitbook/react-contentkit@0.7.9
## 0.20.0
### Minor Changes
- 1d92dc1: Update site layout and blocks with container queries and better transitions
### Patch Changes
- 8fdc5c8: Fix webframe height issue
- 9022d87: Add breadcrumbs to search results
- 0c01832: SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
- 1d1f0a7: Fix search container shadow
- 2aa4f2e: Update dropdown menu styling
- a49781e: Printable collapsed code block
- 7d6a316: Revert contentref anchor title
- e27986b: Reword "Everywhere" search scope to "All docs"
- 758b34d: Move the "Ask <query>" to the bottom of search results if query is not a question
- 0ae7cfd: Improve AI Search branding
- f9f8011: Add alt text support to card covers
- ebbb476: Improve ancestor label in references
- 1b0613e: Tweak language dropdown styling
- 3355960: Fix ToggeableLinkItem display
- c31e564: Fix icon resolution for page group
- 55c0b03: Support translations and generic variants together
- 3876f70: Edgeless Assistant window on `depth: flat` sites
- 683bb8f: Add confirmation to "Copy MCP URL" page action
- 4534ea8: Implement unique title tags for sections & variants
- 6d56df7: Treat pages without visible TOC items as `no-toc`
- 8606f22: Add padding to TOC page group items
- 3676d64: Update search scope UI
- c32d449: Fix OpenAPI path display
- 8ce7322: Add OpenAPI servers selection
- 2c3066e: Improve OAuth2 scopes handling in OpenAPI
- 6b1c9fe: Fix opacity of ScrollContainer buttons on bold themes
- Updated dependencies [b4a021a]
- Updated dependencies [a512c90]
- Updated dependencies [df1966d]
- Updated dependencies [b45feaf]
- Updated dependencies [10995e0]
- Updated dependencies [f9f8011]
- Updated dependencies [10995e0]
- Updated dependencies [8ce7322]
- Updated dependencies [2c3066e]
- @gitbook/react-openapi@1.5.3
- @gitbook/openapi-parser@3.0.5
- @gitbook/react-contentkit@0.7.8
- @gitbook/browser-types@0.1.3
- @gitbook/cache-tags@0.3.4
- @gitbook/react-math@0.6.4
- @gitbook/colors@0.4.3
- @gitbook/embed@0.1.5
- @gitbook/fonts@0.1.3
- @gitbook/icons@0.3.4
- @gitbook/expr@1.2.4
## 0.19.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- 75543b1: Update minimum Node version in .npmrc
- 511f1cf: Add scroll margin to Expandable
- Updated dependencies [6142d6b]
- @gitbook/emoji-codepoints@0.2.2
- @gitbook/react-contentkit@0.7.7
- @gitbook/openapi-parser@3.0.4
- @gitbook/browser-types@0.1.2
- @gitbook/react-openapi@1.5.2
- @gitbook/cache-tags@0.3.3
- @gitbook/react-math@0.6.3
- @gitbook/colors@0.4.2
- @gitbook/fonts@0.1.2
- @gitbook/icons@0.3.3
- @gitbook/expr@1.2.3
## 0.19.1 ## 0.19.1
### Patch Changes ### Patch Changes
+10 -5
View File
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://adiblar.gitbook.io', contentBaseURL: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
// { {
// name: 'docs.gradient.network', name: 'docs.gradient.network',
// contentBaseURL: 'https://docs.gradient.network', contentBaseURL: 'https://docs.gradient.network',
// tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
// }, },
// { // {
// name: 'mygate-network.gitbook.io', // name: 'mygate-network.gitbook.io',
// contentBaseURL: 'https://mygate-network.gitbook.io', // contentBaseURL: 'https://mygate-network.gitbook.io',
@@ -172,6 +172,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.realapp.link', contentBaseURL: 'https://docs.realapp.link',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
{
name: 'docs.plaza.finance',
contentBaseURL: 'https://docs.plaza.finance',
tests: [{ name: 'Home', url: '/' }],
},
{ {
name: 'docs.publicai.io', name: 'docs.publicai.io',
contentBaseURL: 'https://docs.publicai.io', contentBaseURL: 'https://docs.publicai.io',
+35 -165
View File
@@ -33,35 +33,9 @@ import {
headerLinks, headerLinks,
runTestCases, runTestCases,
waitForCookiesDialog, waitForCookiesDialog,
waitForCoverImages,
waitForNotFound, waitForNotFound,
} from './util'; } from './util';
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-time-greeting"]');
if (greeting) {
greeting.textContent = 'Good morning';
}
};
const overrideAIResponse = () => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (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';
});
};
const searchTestCases: Test[] = [ const searchTestCases: Test[] = [
{ {
name: 'Search - AI Mode: None - Complete flow', name: 'Search - AI Mode: None - Complete flow',
@@ -78,9 +52,7 @@ const searchTestCases: Test[] = [
// Fill search input, expecting search results // Fill search input, expecting search results
await searchInput.fill('gitbook'); await searchInput.fill('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible({ await expect(page.getByTestId('search-results')).toBeVisible();
timeout: 10_000,
});
const pageResults = await page.getByTestId('search-page-result').all(); const pageResults = await page.getByTestId('search-page-result').all();
await expect(pageResults.length).toBeGreaterThanOrEqual(1); await expect(pageResults.length).toBeGreaterThanOrEqual(1);
const pageSectionResults = await page.getByTestId('search-page-section-result').all(); const pageSectionResults = await page.getByTestId('search-page-section-result').all();
@@ -125,6 +97,9 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible(); await expect(page.getByTestId('search-results')).toBeVisible();
}, },
}, },
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Search - AI Mode: Search - Complete flow
// - Search - AI Mode: Search - URL query (Initial)
{ {
name: 'Search - AI Mode: Search - URL query (Results)', name: 'Search - AI Mode: Search - URL query (Results)',
url: `${getCustomizationURL({ url: `${getCustomizationURL({
@@ -132,12 +107,16 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Search, mode: CustomizationAIMode.Search,
}, },
})}&q=gitbook`, })}&q=gitbook`,
screenshot: false,
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused(); await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook'); await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible(); await expect(page.getByTestId('search-results')).toBeVisible();
}, },
}, },
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Ask - AI Mode: Search - URL query (Ask initial)
// - Ask - AI Mode: Search - URL query (Ask results)
{ {
name: 'Ask - AI Mode: Assistant - Complete flow', name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({ url: getCustomizationURL({
@@ -145,32 +124,25 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
}), }),
screenshot: false,
run: async (page) => { run: async (page) => {
const searchInput = page.locator('css=[data-testid="search-input"]'); const searchInput = page.locator('css=[data-testid="search-input"]');
// Focus search input, expecting recommended questions // Focus search input, expecting recommended questions
await searchInput.focus(); await searchInput.focus();
await expect(page.getByTestId('search-results')).toBeVisible({ // TODO: Re-enable this part of the test when we have fixed the AI Search timing out
timeout: 30_000, // await expect(page.getByTestId('search-results')).toBeVisible();
}); // const recommendedQuestions = await page
const recommendedQuestions = await page // .getByTestId('search-recommended-question')
.getByTestId('search-recommended-question') // .all();
.all(); // await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// Fill search input, expecting AI search option // Fill search input, expecting AI search option
await searchInput.fill(AI_PROMPT); await searchInput.fill('What is gitbook?');
const aiSearchResult = page.getByTestId('search-ask-question'); const aiSearchResult = page.getByTestId('search-ask-question');
await expect(aiSearchResult).toBeVisible(); await expect(aiSearchResult).toBeVisible();
await aiSearchResult.click(); await aiSearchResult.click();
await expect(page.getByTestId('ai-chat')).toBeVisible(); 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);
}, },
}, },
{ {
@@ -180,12 +152,11 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
}), }),
screenshot: false,
run: async (page) => { run: async (page) => {
await page.keyboard.press('ControlOrMeta+I'); await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
}, },
}, },
{ {
@@ -200,8 +171,6 @@ const searchTestCases: Test[] = [
await page.getByTestId('ai-chat-button').click(); await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
}, },
}, },
{ {
@@ -211,13 +180,12 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
})}&ask=`, })}&ask=`,
screenshot: false,
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused(); await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).toBeEmpty(); await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
}, },
}, },
{ {
@@ -226,18 +194,17 @@ const searchTestCases: Test[] = [
ai: { ai: {
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
})}&ask=${encodeURIComponent(AI_PROMPT)}`, })}&ask=What+is+GitBook%3F`,
screenshot: false,
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused(); await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?'); await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible({
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT); timeout: 15_000,
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 expect(page.getByTestId('ai-chat-message').first()).toHaveText(
await page.evaluate(overrideAIResponse); 'What is GitBook?'
);
}, },
}, },
]; ];
@@ -261,58 +228,6 @@ const testCases: TestsCase[] = [
); );
}, },
}, },
{
name: 'Expandable TOC navigation',
url: '',
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Navigation" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Navigation' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// Verify "Navigation" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
{
name: 'Expandable nested TOC navigation',
url: '',
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Spaces" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Spaces' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// At this stage the link should still not be visible
await expect(navigationLink).not.toBeVisible();
// Then we click 'Content Structure' chevron to expand further
const contentStructureChevron = page
.getByRole('link', { name: 'Content Structure' })
.locator('span');
await contentStructureChevron.click();
// Verify "Spaces" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
...searchTestCases, ...searchTestCases,
{ {
name: 'Not found', name: 'Not found',
@@ -370,37 +285,6 @@ const testCases: TestsCase[] = [
).toBeVisible(); ).toBeVisible();
}, },
}, },
{
name: 'Switch variant with alternate link in metadata',
url: 'rfcs',
run: async (page) => {
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the variant space called 'Multi-Variants' for which
// there is an alternate link in the current (RFC variant) page metadata
await variantSelectionDropdown
.getByRole('menuitem', {
name: 'Multi-Variants',
})
.click();
// It should navigate to the alternate link defined in the metadata (a completely different page)
await page.waitForURL((url) =>
url.pathname.includes('multi-variants/reference/api-reference/pets')
);
// Verify we are on the correct page by checking the h1
await expect(
page.getByRole('heading', { level: 1, name: 'Pets' })
).toBeVisible();
},
},
], ],
}, },
{ {
@@ -843,10 +727,7 @@ const testCases: TestsCase[] = [
url: 'blocks/integrations', url: 'blocks/integrations',
run: async (page) => { run: async (page) => {
await waitForCookiesDialog(page); await waitForCookiesDialog(page);
const mermaidIframe = page const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
.locator('iframe[title*="mermaid"]')
.first()
.contentFrame();
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible(); await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible(); await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
}, },
@@ -912,11 +793,6 @@ const testCases: TestsCase[] = [
url: 'blocks/cards', url: 'blocks/cards',
fullPage: true, fullPage: true,
}, },
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
},
{ {
name: 'Math', name: 'Math',
url: 'blocks/math', url: 'blocks/math',
@@ -975,10 +851,7 @@ const testCases: TestsCase[] = [
{ {
name: 'With cover', name: 'With cover',
url: 'page-options/page-with-cover', url: 'page-options/page-with-cover',
run: async (page) => { run: waitForCookiesDialog,
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
}, },
{ {
name: 'With cover for dark mode', name: 'With cover for dark mode',
@@ -993,18 +866,12 @@ const testCases: TestsCase[] = [
{ {
name: 'With hero cover', name: 'With hero cover',
url: 'page-options/page-with-hero-cover', url: 'page-options/page-with-hero-cover',
run: async (page) => { run: waitForCookiesDialog,
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
}, },
{ {
name: 'With cover and no TOC', name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc', url: 'page-options/page-with-cover-and-no-toc',
run: async (page) => { run: waitForCookiesDialog,
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
screenshot: { screenshot: {
waitForTOCScrolling: false, waitForTOCScrolling: false,
}, },
@@ -1291,7 +1158,7 @@ const testCases: TestsCase[] = [
name: 'Redirect to Quickstart page', name: 'Redirect to Quickstart page',
url: 'sections-2/redirect-test', url: 'sections-2/redirect-test',
run: async (page) => { run: async (page) => {
await expect(page.locator('h1')).toContainText('Quickstart'); await expect(page.locator('h1')).toHaveText('Quickstart');
}, },
screenshot: false, screenshot: false,
}, },
@@ -1587,7 +1454,10 @@ const testCases: TestsCase[] = [
locale, locale,
}, },
}), }),
run: waitForCookiesDialog, run: async (page) => {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible();
},
})), })),
}, },
{ {
+6 -17
View File
@@ -143,10 +143,7 @@ export const headerLinks: CustomizationHeaderItem[] = [
export async function waitForCookiesDialog(page: Page) { export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog'); const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({ await expect(dialog).toBeVisible();
// Cookies dialog may take some times to appear
timeout: 10_000,
});
} }
export async function waitForNotFound(_page: Page, response: Response | null) { export async function waitForNotFound(_page: Page, response: Response | null) {
@@ -154,13 +151,6 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404); expect(response?.status()).toBe(404);
} }
export async function waitForCoverImages(page: Page) {
// Wait for cover images to exist (not the shimmer placeholder)
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
timeout: 10_000,
});
}
/** /**
* Transform test cases into Playwright tests and run it. * Transform test cases into Playwright tests and run it.
*/ */
@@ -417,15 +407,14 @@ export async function waitForIcons(page: Page) {
return true; return true;
} }
const maskImage = icon.querySelector('[data-testid="mask-image"]'); // url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
if (!maskImage) { const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
throw new Error('No mask-image element'); const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
} const url = urlMatch?.[1];
const url = maskImage.getAttribute('href');
// If URL is invalid we throw an error. // If URL is invalid we throw an error.
if (!url) { if (!url) {
throw new Error('No mask-image url'); throw new Error('No mask-image');
} }
// If the URL is already queued for loading, we return the state. // If the URL is already queued for loading, we return the state.
+2 -4
View File
@@ -7,8 +7,7 @@ export default {
converter: 'edge', converter: 'edge',
proxyExternalRequest: 'fetch', proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default), queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
import('./openNext/incrementalCache/server').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}, },
@@ -19,8 +18,7 @@ export default {
converter: 'edge', converter: 'edge',
proxyExternalRequest: 'fetch', proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default), queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
import('./openNext/incrementalCache/middleware').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}, },
@@ -1,13 +1,15 @@
{ {
"main": "default.js", "main": "default.js",
"name": "gitbook-open-v2-server", "name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"observability": {
"enabled": true
},
"vars": { "vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
}, },
@@ -2,12 +2,14 @@
"main": "do.js", "main": "do.js",
"name": "gitbook-open-v2-do", "name": "gitbook-open-v2-do",
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"observability": {
"enabled": true
},
"env": { "env": {
"preview": { "preview": {
"vars": { "vars": {
@@ -2,7 +2,6 @@
"main": "middleware.js", "main": "middleware.js",
"name": "gitbook-open-v2", "name": "gitbook-open-v2",
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
@@ -12,6 +11,9 @@
"directory": "../../.open-next/assets", "directory": "../../.open-next/assets",
"binding": "ASSETS" "binding": "ASSETS"
}, },
"observability": {
"enabled": true
},
"vars": { "vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
}, },
@@ -7,6 +7,8 @@ import type {
} from '@opennextjs/aws/types/overrides.js'; } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare'; import { getCloudflareContext } from '@opennextjs/cloudflare';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
@@ -21,7 +23,7 @@ export type KeyOptions = {
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional * It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast. * R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
*/ */
export class GitbookIncrementalCache implements IncrementalCache { class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache'; name = 'GitbookIncrementalCache';
async get<CacheType extends CacheEntryType = 'cache'>( async get<CacheType extends CacheEntryType = 'cache'>(
@@ -135,3 +137,12 @@ export class GitbookIncrementalCache implements IncrementalCache {
); );
} }
} }
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 */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,11 +0,0 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
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 */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,12 +0,0 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
+16 -22
View File
@@ -1,14 +1,12 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.21.1", "version": "0.19.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*", "@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
"@gitbook/embed": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*", "@gitbook/expr": "workspace:*",
"@gitbook/fonts": "workspace:*", "@gitbook/fonts": "workspace:*",
@@ -17,9 +15,8 @@
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "1.17.5", "@modelcontextprotocol/sdk": "^1.17.5",
"@opennextjs/aws": "^3.8.5", "@opennextjs/cloudflare": "^1.9.2",
"@opennextjs/cloudflare": "^1.14.4",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
@@ -31,16 +28,19 @@
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2", "@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2", "ai": "^4.2.2",
"assert-never": "catalog:", "assert-never": "^1.2.1",
"bidc": "catalog:", "bidc": "catalog:",
"classnames": "catalog:", "bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1", "direction": "^2.0.1",
"event-iterator": "^2.0.0", "event-iterator": "^2.0.0",
"feed": "^5.1.0", "framer-motion": "^10.16.14",
"image-size": "^2.0.2", "image-size": "^2.0.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mcp-handler": "^1.0.2", "mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2", "mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1", "mdast-util-frontmatter": "^2.0.1",
@@ -49,8 +49,8 @@
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24", "motion": "^12.23.12",
"next": "15.4.10", "next": "15.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
@@ -58,8 +58,6 @@
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.3", "p-map": "^7.0.3",
"quick-lru": "^7.0.1", "quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
@@ -75,7 +73,7 @@
"unist-util-remove": "^4.0.0", "unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-join": "^5.0.0", "url-join": "^5.0.0",
"usehooks-ts": "catalog:", "usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1", "warn-once": "^0.1.1",
"zod": "^3", "zod": "^3",
"zustand": "^5.0.3" "zustand": "^5.0.3"
@@ -83,7 +81,6 @@
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^5.0.9", "@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2", "@playwright/test": "^1.54.2",
"@scalar/api-client-react": "catalog:",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5", "@types/jsontoxml": "^1.0.5",
@@ -92,10 +89,9 @@
"@types/node": "^20", "@types/node": "^20",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4", "@types/parse-cache-control": "^1.0.4",
"@types/react": "catalog:", "@types/react": "18.3.13",
"@types/react-dom": "catalog:", "@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
"bun-types": "catalog:",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -103,10 +99,8 @@
"stylelint": "^16.16.0", "stylelint": "^16.16.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1", "ts-essentials": "^10.0.1",
"typescript": "catalog:", "typescript": "^5.5.3",
"vercel": "^39.3.0", "vercel": "^39.3.0"
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
}, },
"scripts": { "scripts": {
"generate": "./scripts/generate.sh", "generate": "./scripts/generate.sh",
@@ -16,7 +16,7 @@ export default async function Page(props: PageProps) {
const { context } = await getDynamicSiteContext(params); const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params); const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} staticRoute={false} />; return <SitePage context={context} pageParams={{ pathname }} />;
} }
export async function generateViewport(props: PageProps): Promise<Viewport> { export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -22,11 +22,7 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers()); const withTracking = shouldTrackEvents(await headers());
return ( return (
<CustomizationRootLayout <CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
className="site-background"
forcedTheme={forcedTheme}
context={context}
>
<SiteLayout <SiteLayout
context={context} context={context}
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
@@ -1,28 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils'; import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable'; import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable'; import { getEmbeddableDynamicContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation'; export const dynamic = 'force-static';
type PageProps = { type PageProps = {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
}; };
export const dynamic = 'force-static';
export default async function Page(props: PageProps) { export default async function Page(props: PageProps) {
const params = await props.params; const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params); const { context } = await getEmbeddableDynamicContext(params);
// If the assistant is not enabled, redirect to the docs return <EmbeddableAssistantPage context={context} />;
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
}
return (
<EmbeddableAssistantPage
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
);
} }
@@ -1,21 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// If assistant is enabled, redirect to assistant, otherwise to docs
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
redirect(`${baseURL}/assistant`);
} else {
redirect(`${baseURL}/page/`);
}
}
@@ -12,7 +12,7 @@ export default async function Page(props: PageProps) {
const { context } = await getEmbeddableDynamicContext(params); const { context } = await getEmbeddableDynamicContext(params);
const pathname = getPagePathFromParams(params); const pathname = getPagePathFromParams(params);
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} staticRoute={false} />; return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
} }
export async function generateMetadata(props: PageProps): Promise<Metadata> { export async function generateMetadata(props: PageProps): Promise<Metadata> {
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
const { context } = await getStaticSiteContext(params); const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params); const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} staticRoute />; return <SitePage context={context} pageParams={{ pathname }} />;
} }
export async function generateViewport(props: PageProps): Promise<Viewport> { export async function generateViewport(props: PageProps): Promise<Viewport> {
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents(); const withTracking = shouldTrackEvents();
return ( return (
<CustomizationRootLayout className="site-background" context={context}> <CustomizationRootLayout context={context}>
<SiteLayout <SiteLayout
context={context} context={context}
withTracking={withTracking} withTracking={withTracking}
@@ -1,28 +1,16 @@
import type { RouteParams } from '@/app/utils'; import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable'; import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable'; import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
type PageProps = { type PageProps = {
params: Promise<RouteParams>; params: Promise<RouteLayoutParams>;
}; };
export default async function Page(props: PageProps) { export default async function Page(props: PageProps) {
const params = await props.params; const params = await props.params;
const { context } = await getEmbeddableStaticContext(params); const { context } = await getEmbeddableStaticContext(params);
// If the assistant is not enabled, redirect to the docs return <EmbeddableAssistantPage context={context} />;
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
}
return (
<EmbeddableAssistantPage
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
);
} }
@@ -1,23 +0,0 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// If assistant is enabled, redirect to assistant, otherwise to docs
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
redirect(`${baseURL}/assistant`);
} else {
redirect(`${baseURL}/page/`);
}
}
@@ -14,7 +14,7 @@ export default async function Page(props: PageProps) {
const { context } = await getEmbeddableStaticContext(params); const { context } = await getEmbeddableStaticContext(params);
const pathname = getPagePathFromParams(params); const pathname = getPagePathFromParams(params);
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} staticRoute />; return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
} }
export async function generateMetadata(props: PageProps): Promise<Metadata> { export async function generateMetadata(props: PageProps): Promise<Metadata> {
@@ -1,12 +0,0 @@
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
import { servePageRSS } from '@/routes/rss';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageRSS(context, pathname);
}
+1 -21
View File
@@ -3,7 +3,7 @@ import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@
import { getDynamicCustomizationSettings } from '@/lib/customization'; import { getDynamicCustomizationSettings } from '@/lib/customization';
import type { SiteAPIToken } from '@gitbook/api'; import type { SiteAPIToken } from '@gitbook/api';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import { forbidden, notFound } from 'next/navigation'; import { forbidden } from 'next/navigation';
import rison from 'rison'; import rison from 'rison';
export type RouteParamMode = 'url-host' | 'url'; export type RouteParamMode = 'url-host' | 'url';
@@ -80,27 +80,14 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
* Get the decoded page path from the params. * Get the decoded page path from the params.
*/ */
export function getPagePathFromParams(params: RouteParams) { export function getPagePathFromParams(params: RouteParams) {
// If decoding the param fails, return a 404 instead of crashing
try {
const decoded = decodeURIComponent(params.pagePath); const decoded = decodeURIComponent(params.pagePath);
return decoded; return decoded;
} catch (error) {
console.error(
`Returning 404 after failing to decode page path ${params.pagePath}: ${error}`
);
notFound();
}
} }
function getSiteURLFromParams(params: RouteLayoutParams) { function getSiteURLFromParams(params: RouteLayoutParams) {
try {
const decoded = decodeURIComponent(params.siteURL); const decoded = decodeURIComponent(params.siteURL);
const url = new URL(`https://${decoded}`); const url = new URL(`https://${decoded}`);
return url; return url;
} catch (error) {
console.error(`Returning 404 after failing to decode site URL ${params.siteURL}: ${error}`);
notFound();
}
} }
function getModeFromParams(mode: string): RouteParamMode { function getModeFromParams(mode: string): RouteParamMode {
@@ -115,13 +102,6 @@ function getModeFromParams(mode: string): RouteParamMode {
* Get the decoded site data from the params. * Get the decoded site data from the params.
*/ */
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData { function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
try {
const decoded = decodeURIComponent(params.siteData); const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded); return rison.decode(decoded);
} catch (error) {
console.error(
`Returning 404 after failing to decode site data ${params.siteData}: ${error}`
);
notFound();
}
} }
@@ -16,14 +16,14 @@ export function AIMessageView(
) { ) {
const { message, context, withToolCalls = true, withLinkPreviews = true } = props; const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return message.steps.length > 0 ? ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{message.steps.map((step, index) => { {message.steps.map((step, index) => {
return ( return (
<div <div
key={index} key={index}
className={tcls( className={tcls(
'flex flex-col gap-2', 'flex animate-fade-in-slow flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : '' step.content.nodes.length > 0 ? 'has-content' : ''
)} )}
> >
@@ -35,7 +35,7 @@ export function AIMessageView(
wrapBlocksInSuspense: false, wrapBlocksInSuspense: false,
withLinkPreviews, withLinkPreviews,
}} }}
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow" style="mt-2 space-y-4 empty:hidden"
/> />
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? ( {withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
@@ -45,5 +45,5 @@ export function AIMessageView(
); );
})} })}
</div> </div>
) : null; );
} }
@@ -39,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props; const { toolCall, context } = props;
return ( return (
<div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle"> <div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon <Icon
icon={getIconForToolCall(toolCall)} icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8" className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -160,7 +160,7 @@ async function DescriptionForSearchToolCall(props: {
const hasResults = toolCall.results.length > 0; const hasResults = toolCall.results.length > 0;
return ( return (
<details className="-ml-5 group flex w-full flex-col"> <details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<summary <summary
className={tcls( className={tcls(
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden', '-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
) : null} ) : null}
</summary> </summary>
{hasResults ? ( {hasResults ? (
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11"> <div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1"> <ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => ( {searchResultsWithHrefs.map((result, index) => (
<li <li
@@ -128,10 +128,10 @@ function parseResponse<T>(
parse: (response: AIStreamResponse) => T | undefined | Promise<T | undefined> parse: (response: AIStreamResponse) => T | undefined | Promise<T | undefined>
): { ): {
stream: EventIterator<T>; stream: EventIterator<T>;
response: Promise<{ responseId: string | null }>; response: Promise<{ responseId: string }>;
} { } {
let resolveResponse: (value: { responseId: string | null }) => void; let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string | null }>((resolve) => { const response = new Promise<{ responseId: string }>((resolve) => {
resolveResponse = resolve; resolveResponse = resolve;
}); });
@@ -147,7 +147,7 @@ function parseResponse<T>(
if (event.type === 'response_finish') { if (event.type === 'response_finish') {
foundResponse = true; foundResponse = true;
resolveResponse({ responseId: event.response.id ?? null }); resolveResponse({ responseId: event.responseId });
} }
} }
+14 -14
View File
@@ -1,26 +1,21 @@
'use client'; 'use client';
import { CustomizationAIMode } from '@gitbook/api'; import { CustomizationAIMode } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import * as React from 'react'; import * as React from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { tString, useLanguage } from '@/intl/client'; import { tString, useLanguage } from '@/intl/client';
import type { GitBookAssistant } from '@gitbook/browser-types'; import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.'; import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat'; import { AIChatIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations'; import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch'; import { useSearch } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place // Unify assistants configuration context with the assistants hook in one place
export type AIConfig = { export type AIConfig = {
aiMode: CustomizationAIMode; aiMode: CustomizationAIMode;
suggestions?: string[];
trademark: boolean; trademark: boolean;
greeting?: {
title: string;
subtitle: string;
};
}; };
export type Assistant = Omit<GitBookAssistant, 'icon'> & { export type Assistant = Omit<GitBookAssistant, 'icon'> & {
@@ -54,11 +49,8 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
const AIContext = React.createContext<AIConfig | null>(null); const AIContext = React.createContext<AIConfig | null>(null);
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement { export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
const { aiMode, trademark, suggestions, greeting, children } = props; const { aiMode, trademark, children } = props;
const value = React.useMemo( const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
() => ({ aiMode, trademark, suggestions, greeting }),
[aiMode, trademark, suggestions, greeting]
);
return <AIContext.Provider value={value}>{children}</AIContext.Provider>; return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
} }
@@ -96,7 +88,6 @@ export function useAI(): AIContext {
<AIChatIcon <AIChatIcon
state={chat.loading ? 'thinking' : 'default'} state={chat.loading ? 'thinking' : 'default'}
trademark={config.trademark} trademark={config.trademark}
className="size-4"
/> />
), ),
open: (query?: string) => { open: (query?: string) => {
@@ -113,7 +104,16 @@ export function useAI(): AIContext {
assistants.push({ assistants.push({
id: 'gitbook-ai-search', id: 'gitbook-ai-search',
label: tString(language, 'ai_chat_context_badge'), label: tString(language, 'ai_chat_context_badge'),
icon: <AISearchIcon />, icon: (
<div className="relative">
<Icon icon="search" className="size-4" />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
className="absolute top-[2.5px] left-[2.6px] size-2"
/>
</div>
),
open: (query?: string) => { open: (query?: string) => {
if (query) { if (query) {
setSearchState((prev) => setSearchState((prev) =>
@@ -87,19 +87,6 @@ export type AIChatState = {
error: boolean; error: boolean;
}; };
export type AIChatEvent =
| { type: 'open' }
| { type: 'postMessage'; message: string }
| { type: 'clear' }
| { type: 'close' };
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
'type'
>;
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = { export type AIChatController = {
/** Open the dialog */ /** Open the dialog */
open: () => void; open: () => void;
@@ -109,11 +96,6 @@ export type AIChatController = {
postMessage: (input: { message: string }) => void; postMessage: (input: { message: string }) => void;
/** Clear the conversation */ /** Clear the conversation */
clear: () => void; clear: () => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => () => void;
}; };
const AIChatControllerContext = React.createContext<AIChatController | null>(null); const AIChatControllerContext = React.createContext<AIChatController | null>(null);
@@ -141,17 +123,6 @@ export function useAIChatState(): AIChatState {
return state; return state;
} }
function notify(
listeners: AIChatEventListener[] | undefined,
input: Omit<AIChatEvent, 'type'>
): void {
if (!listeners) return;
// Defer event listeners to next tick so React can process state updates first
setTimeout(() => {
listeners.forEach((listener) => listener(input));
}, 0);
}
/** /**
* Provide the controller to interact with the AI chat. * Provide the controller to interact with the AI chat.
*/ */
@@ -166,9 +137,6 @@ export function AIChatProvider(props: {
const [, setSearchState] = useSearch(); const [, setSearchState] = useSearch();
const language = useLanguage(); const language = useLanguage();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state // Open AI chat and sync with search state
const onOpen = React.useCallback(() => { const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState(); const { initialQuery } = globalState.getState();
@@ -181,8 +149,6 @@ export function AIChatProvider(props: {
scope: prev?.scope ?? 'default', scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat open: false, // Close search popover when opening chat
})); }));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]); }, [setSearchState]);
// Close AI chat and clear ask parameter // Close AI chat and clear ask parameter
@@ -196,8 +162,6 @@ export function AIChatProvider(props: {
scope: prev?.scope ?? 'default', scope: prev?.scope ?? 'default',
open: false, open: false,
})); }));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]); }, [setSearchState]);
// Stream a message with the AI backend // Stream a message with the AI backend
@@ -296,7 +260,7 @@ export function AIChatProvider(props: {
case 'response_finish': { case 'response_finish': {
globalState.setState((state) => ({ globalState.setState((state) => ({
...state, ...state,
responseId: event.response.id ?? null, responseId: event.responseId,
// Mark as not loading when the response is finished // Mark as not loading when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion' // Even if the stream might continue as we receive 'response_followup_suggestion'
loading: false, loading: false,
@@ -415,14 +379,8 @@ export function AIChatProvider(props: {
})); }));
} }
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message) { if (query === input.message) {
// Return early if the message is the same as the previous message // Return early if the message is the same as the previous message
globalState.setState((state) => ({
...state,
opened: true,
}));
return; return;
} }
@@ -482,34 +440,14 @@ export function AIChatProvider(props: {
})); }));
}, [setSearchState]); }, [setSearchState]);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => {
const listeners = eventsRef.current.get(event) || [];
listeners.push(listener as AIChatEventListener);
eventsRef.current.set(event, listeners);
return () => {
const currentListeners = eventsRef.current.get(event) || [];
eventsRef.current.set(
event,
currentListeners.filter((l) => l !== listener)
);
};
},
[]
);
const controller = React.useMemo(() => { const controller = React.useMemo(() => {
return { return {
open: onOpen, open: onOpen,
close: onClose, close: onClose,
clear: onClear, clear: onClear,
postMessage: onPostMessage, postMessage: onPostMessage,
on: onEvent,
}; };
}, [onOpen, onClose, onClear, onPostMessage, onEvent]); }, [onOpen, onClose, onClear, onPostMessage]);
return ( return (
<AIChatControllerContext.Provider value={controller}> <AIChatControllerContext.Provider value={controller}>
@@ -2,14 +2,12 @@
import { t, tString, useLanguage } from '@/intl/client'; import { t, tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations'; import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { import {
type AIChatController, type AIChatController,
type AIChatState, type AIChatState,
useAI,
useAIChatController, useAIChatController,
useAIChatState, useAIChatState,
} from '../AI'; } from '../AI';
@@ -19,22 +17,21 @@ import {
EmbeddableFrameButtons, EmbeddableFrameButtons,
EmbeddableFrameHeader, EmbeddableFrameHeader,
EmbeddableFrameHeaderMain, EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSubtitle, EmbeddableFrameSubtitle,
EmbeddableFrameTitle, EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame'; } from '../Embeddable/EmbeddableFrame';
import { useTrackEvent } from '../Insights'; import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks'; import { useNow } from '../hooks';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { AIChatControlButton } from './AIChatControlButton'; import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon'; import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput'; import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages'; import { AIChatMessages } from './AIChatMessages';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions'; import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
export function AIChat() { export function AIChat(props: { trademark: boolean }) {
const { config } = useAI(); const { trademark } = props;
const language = useLanguage(); const language = useLanguage();
const chat = useAIChatState(); const chat = useAIChatState();
const chatController = useAIChatController(); const chatController = useAIChatController();
@@ -68,23 +65,21 @@ export function AIChat() {
} }
}, [chat.opened, trackEvent]); }, [chat.opened, trackEvent]);
if (!chat.opened) {
return null;
}
return ( return (
<div <div
data-testid="ai-chat" data-testid="ai-chat"
className={tcls( className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96',
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
)}
> >
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96"> <EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
<EmbeddableFrameMain>
<EmbeddableFrameHeader> <EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} /> <AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain> <EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle> <EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)} {getAIChatName(language, trademark)}
</EmbeddableFrameTitle> </EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} /> <AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain> </EmbeddableFrameHeaderMain>
@@ -96,17 +91,13 @@ export function AIChat() {
icon="close" icon="close"
label={tString(language, 'close')} label={tString(language, 'close')}
variant="blank" variant="blank"
size="default"
/> />
</EmbeddableFrameButtons> </EmbeddableFrameButtons>
</EmbeddableFrameHeader> </EmbeddableFrameHeader>
<EmbeddableFrameBody> <EmbeddableFrameBody>
<AIChatBody <AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
chatController={chatController}
chat={chat}
suggestions={config.suggestions}
/>
</EmbeddableFrameBody> </EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame> </EmbeddableFrame>
</div> </div>
); );
@@ -117,14 +108,13 @@ export function AIChat() {
*/ */
export function AIChatDynamicIcon(props: { export function AIChatDynamicIcon(props: {
trademark: boolean; trademark: boolean;
className?: string;
}) { }) {
const { trademark, className } = props; const { trademark } = props;
const chat = useAIChatState(); const chat = useAIChatState();
return ( return (
<AIChatIcon <AIChatIcon
className={tcls('size-5 text-tint', className)} className="size-5 text-tint"
trademark={trademark} trademark={trademark}
state={ state={
chat.error chat.error
@@ -153,33 +143,10 @@ export function AIChatSubtitle(props: {
const language = useLanguage(); const language = useLanguage();
return ( return (
<EmbeddableFrameSubtitle <EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')} {chat.messages[chat.messages.length - 1]?.content
> ? tString(language, 'ai_chat_working')
<span : tString(language, 'ai_chat_thinking')}
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-slow'
: 'hidden'
: 'animate-blur-out-slow'
)}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-slow'
: 'animate-blur-in-slow'
: 'hidden'
)}
>
{t(language, 'ai_chat_thinking')}
</span>
</EmbeddableFrameSubtitle> </EmbeddableFrameSubtitle>
); );
} }
@@ -190,16 +157,20 @@ export function AIChatSubtitle(props: {
export function AIChatBody(props: { export function AIChatBody(props: {
chatController: AIChatController; chatController: AIChatController;
chat: AIChatState; chat: AIChatState;
trademark: boolean;
welcomeMessage?: string; welcomeMessage?: string;
suggestions?: string[]; suggestions?: string[];
greeting?: {
title: string;
subtitle: string;
};
}) { }) {
const { chatController, chat, suggestions, greeting } = props; const { chatController, chat, trademark, suggestions } = props;
const { trademark } = useAI().config;
const [input, setInput] = React.useState('');
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
// Ref for the last user message element
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLDivElement>(null);
const [inputHeight, setInputHeight] = React.useState(0);
const language = useLanguage(); const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -213,45 +184,68 @@ export function AIChatBody(props: {
return tString(language, 'ai_chat_assistant_greeting_evening'); return tString(language, 'ai_chat_assistant_greeting_evening');
}, [now, language]); }, [now, language]);
// Auto-scroll to the latest user message when messages change
React.useEffect(() => {
if (chat.messages.length > 0 && lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [chat.messages.length]);
React.useEffect(() => {
const timeout = setTimeout(() => {
if (lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, 100);
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
setInputHeight(entry.contentRect.height + 32);
});
});
if (inputRef.current) {
observer.observe(inputRef.current);
}
return () => {
observer.disconnect();
clearTimeout(timeout);
};
}, []);
return ( return (
<> <>
<ScrollContainer <div
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]" ref={scrollContainerRef}
contentClassName="p-4 gutter-stable flex flex-col gap-4" className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
orientation="vertical" style={{
fadeEdges={['leading']} paddingBottom: `${inputHeight}px`,
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`} }}
> >
{isEmpty ? ( {isEmpty ? (
<div className="flex grow flex-col"> <div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
<div className="my-auto flex flex-row items-center gap-4 pb-6 [@container(min-height:400px)]:flex-col"> <div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
<div
className="flex size-16 shrink-0 animate-scale-in items-center justify-center rounded-full bg-primary-solid/1 [@container(min-height:400px)]:size-32"
style={{ animationDelay: '.3s' }}
>
<AIChatIcon <AIChatIcon
state="intro" state="intro"
trademark={trademark} trademark={trademark}
className="size-8 text-primary [@container(min-height:400px)]:size-16" className="size-16 animate-[present_500ms_200ms_both]"
/> />
</div> </div>
<div className="flex flex-col items-start gap-1 [@container(min-height:400px)]:items-center"> <div className="animate-[fadeIn_500ms_400ms_both]">
<h5 <h5 className=" text-center font-bold text-lg text-tint-strong">
className="animate-blur-in-slow font-bold text-lg text-tint-strong leading-tight [@container(min-height:400px)]:text-center" {timeGreeting}
style={{ animationDelay: '.5s' }}
data-testid="ai-chat-time-greeting"
>
{greeting?.title || timeGreeting}
</h5> </h5>
<p <p className="text-center text-tint">
className="animate-blur-in-slow text-tint leading-tight [@container(min-height:400px)]:text-center" {t(language, 'ai_chat_assistant_description')}
style={{ animationDelay: '.6s' }}
>
{greeting?.subtitle ||
t(language, 'ai_chat_assistant_description')}
</p> </p>
</div> </div>
</div>
{!chat.error ? ( {!chat.error ? (
<AIChatSuggestedQuestions <AIChatSuggestedQuestions
chatController={chatController} chatController={chatController}
@@ -260,19 +254,28 @@ export function AIChatBody(props: {
) : null} ) : null}
</div> </div>
) : ( ) : (
<AIChatMessages chat={chat} chatController={chatController} /> <AIChatMessages
chat={chat}
chatController={chatController}
lastUserMessageRef={lastUserMessageRef}
/>
)} )}
</ScrollContainer> </div>
<div
<div className="flex flex-col gap-2 px-4 pb-4"> ref={inputRef}
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
>
{/* Display an error banner when something went wrong. */} {/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null} {chat.error ? <AIChatError chatController={chatController} /> : null}
<AIChatInput <AIChatInput
value={input}
onChange={setInput}
loading={chat.loading} loading={chat.loading}
disabled={chat.loading || chat.error} disabled={chat.loading || chat.error}
onSubmit={(value) => { onSubmit={() => {
chatController.postMessage({ message: value }); chatController.postMessage({ message: input });
setInput('');
}} }}
/> />
</div> </div>
@@ -23,7 +23,7 @@ export function AIChatButton(props: {
iconOnly={!showLabel} iconOnly={!showLabel}
size="medium" size="medium"
variant="header" variant="header"
className="@max-2xl:px-2.5 @max-2xl:[&_.button-content]:hidden [&_.button-leading-icon]:my-0.5" className="h-9 px-2.5 max-md:[&_.button-content]:hidden"
label={ label={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)} {t(language, 'ai_chat_ask', assistant.label)}
@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useLanguage } from '@/intl/client'; import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate'; import { t, tString } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '../AI'; import { useAIChatController, useAIChatState } from '../AI';
import { Button } from '../primitives'; import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
/** /**
* Button to control the chat (clear, etc.) * Button to control the chat (clear, etc.)
@@ -14,15 +15,26 @@ export function AIChatControlButton() {
const chatController = useAIChatController(); const chatController = useAIChatController();
return chat.messages.length > 0 ? ( return chat.messages.length > 0 ? (
<DropdownMenu
button={
<Button <Button
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'actions')}
variant="blank"
size="default"
/>
}
>
<DropdownMenuItem
onClick={() => { onClick={() => {
chatController.clear(); chatController.clear();
}} }}
iconOnly >
icon="trash-can" <Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
label={t(language, 'ai_chat_clear_conversation')} {t(language, 'ai_chat_clear_conversation')}
variant="blank" </DropdownMenuItem>
className="animate-blur-in-slow" </DropdownMenu>
/>
) : null; ) : null;
} }
@@ -1,4 +1,5 @@
import { Icon, IconStyle } from '@gitbook/icons'; import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import type React from 'react'; import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> { interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
@@ -8,30 +9,23 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
trademark?: boolean; trademark?: boolean;
} }
export function AIChatIcon({ export const AIChatIcon = ({
className = 'size-4', className = 'size-4',
size, size,
trademark = true, trademark = true,
state = 'default', state = 'default',
...props ...props
}: AIChatIconProps) { }: AIChatIconProps) => {
if (!trademark) { if (!trademark) {
return ( return (
<Icon <Icon
icon="sparkle" icon="sparkle"
{...props} className={tcls(
className={className} className,
style={{ (state === 'thinking' || state === 'working') &&
animation: { 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)', state === 'intro' && 'animate-[spin_2s_forwards_cubic-bezier(0.16,1,0.3,1)]'
thinking: '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: '',
default: '',
error: '',
}[state],
}}
/> />
); );
} }
@@ -55,36 +49,28 @@ export function AIChatIcon({
<path <path
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z" d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
stroke="currentColor" stroke="currentColor"
strokeWidth={state === 'intro' ? '1.2' : '1'} strokeWidth="1.2"
strokeLinejoin="round" strokeLinejoin="round"
shapeRendering="crispEdges" className={tcls(
className="transition-opacity duration-300" state === 'intro' &&
style={{ 'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
animation: { (state === 'working' || state === 'thinking') &&
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)', 'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)', state === 'done' && 'animate-[fadeOut_.5s_both]',
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)', state === 'confirm' && 'animate-[fadeOut_.5s_both]',
done: '', state === 'default' && 'animate-[fadeIn_0s_both]',
confirm: '', state === 'error' && 'hidden'
default: '', )}
error: '', style={{ transformOrigin: '13px 3.5px' }}
}[state],
transitionDelay:
state === 'default' || state === 'thinking' ? '.3s' : undefined,
opacity: ['done', 'confirm', 'error'].includes(state) ? 0 : 1,
transformOrigin: '13px 3.5px',
}}
/> />
{/* Error */} {/* Error */}
<g <g
clipPath="url(#clip0_153_2034)" clipPath="url(#clip0_153_2034)"
className="transition-opacity duration-300" className={tcls(
style={{ 'text-danger-subtle',
color: 'rgb(var(--danger-9))', state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
opacity: state === 'error' ? 1 : 0, )}
transitionDelay: state === 'error' ? '.3s' : undefined,
}}
> >
<path <path
d="M13.0312 1.42059L13.0312 3.95184" d="M13.0312 1.42059L13.0312 3.95184"
@@ -107,30 +93,25 @@ export function AIChatIcon({
strokeWidth="1.2" strokeWidth="1.2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="transition-opacity duration-300" className={tcls(
style={{ state === 'done'
opacity: state === 'done' ? 1 : 0, ? 'animate-[fadeIn_.5s_.3s_both]'
transitionDelay: state === 'done' ? '.3s' : undefined, : 'animate-[fadeOut_.5s_both]',
}} state === 'intro' && 'hidden',
state === 'confirm' && 'hidden'
)}
/> />
{/* Confirm */} {/* Confirm */}
<path <path
className="transition-opacity duration-300" className={tcls(
style={{ 'fill-primary-9',
fill: 'rgb(var(--primary-9))', state === 'confirm'
opacity: state === 'confirm' ? 1 : 0, ? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
transitionDelay: state === 'confirm' ? '.3s' : undefined, : state === 'thinking'
animation: { ? 'animate-[fadeOut_.5s_both]'
intro: '', : 'hidden'
thinking: '', )}
working: '',
done: '',
confirm: 'bounceSmall 1s infinite both',
default: '',
error: '',
}[state],
}}
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z" d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
/> />
@@ -142,17 +123,7 @@ export function AIChatIcon({
strokeWidth="1.2" strokeWidth="1.2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
style={{ className={tcls(state === 'intro' && 'animate-[fadeIn_2s_forwards]')}
animation: {
intro: 'fadeIn 2s forwards',
thinking: '',
working: '',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
/> />
{/* Logo */} {/* Logo */}
@@ -182,66 +153,14 @@ export function AIChatIcon({
strokeWidth="1.2" strokeWidth="1.2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
style={{ className={tcls(
animation: { (state === 'thinking' || state === 'working') &&
intro: 'pathEnter 1.5s both ease-out', 'animate-[pathLoading_2s_infinite_both]',
thinking: 'pathLoading 2s infinite both', state === 'intro' && 'animate-[pathEnter_2s_both]',
working: 'pathLoading 2s infinite both', state === 'done' && 'animate-[pathEnter_1s_forwards_ease]'
done: 'pathEnter 1s forwards ease', )}
confirm: '',
default: '',
error: '',
}[state],
}}
/> />
</g> </g>
</svg> </svg>
); );
} };
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
}
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
return (
<div
style={{
position: 'relative',
animation: {
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,',
working: '',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
>
<Icon icon="search" className={className} />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
style={{
position: 'absolute',
top: '15.7%',
left: '15.6%',
width: '50%',
height: '50%',
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)',
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
done: '',
confirm: '',
default: '',
error: '',
}[state],
}}
/>
</div>
);
}
@@ -1,28 +1,38 @@
import { t, tString, useLanguage } from '@/intl/client'; import { t, tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatState } from '../AI/useAIChat'; import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives'; import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { Input } from '../primitives/Input';
export function AIChatInput(props: { export function AIChatInput(props: {
value: string;
disabled?: boolean; disabled?: boolean;
/** /**
* When true, the input is disabled * When true, the input is disabled
*/ */
loading: boolean; loading: boolean;
onChange: (value: string) => void;
onSubmit: (value: string) => void; onSubmit: (value: string) => void;
}) { }) {
const { onSubmit, disabled, loading } = props; const { value, onChange, onSubmit, disabled, loading } = props;
const language = useLanguage(); const language = useLanguage();
const chat = useAIChatState();
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = event.currentTarget;
onChange(textarea.value);
// Auto-resize
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
};
useEffect(() => { useEffect(() => {
if (chat.opened && !disabled && !loading) { if (!disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing // Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers // This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -31,7 +41,7 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} }
}, [disabled, loading, chat.opened]); }, [disabled, loading]);
useHotkeys( useHotkeys(
'mod+i', 'mod+i',
@@ -45,34 +55,58 @@ export function AIChatInput(props: {
); );
return ( return (
<Input <div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
data-testid="ai-chat-input" <textarea
name="ai-chat-input"
multiline
resize
sizing="large"
label="Assistant chat input"
placeholder={tString(language, 'ai_chat_input_placeholder')}
onSubmit={(val) => onSubmit(val as string)}
submitButton={{
label: tString(language, 'send'),
}}
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
rows={1}
maxLength={2048}
keyboardShortcut={
!disabled && !loading
? {
keys: ['mod', 'i'],
className: 'bg-tint-base group-focus-within/input:hidden',
}
: undefined
}
disabled={disabled || loading}
aria-busy={loading}
ref={inputRef} ref={inputRef}
trailing={ disabled={disabled || loading}
<HoverCardRoot openDelay={500}> data-loading={loading}
data-testid="ai-chat-input"
className={tcls(
'resize-none',
'focus:outline-hidden',
'focus:ring-0',
'w-full',
'px-3',
'py-3',
'pb-12',
'h-auto',
'bg-transparent',
'peer',
'max-h-64',
'placeholder:text-tint/8',
'transition-colors',
'disabled:bg-tint-subtle',
'delay-300',
'disabled:delay-0',
'disabled:cursor-not-allowed',
'data-[loading=true]:cursor-progress',
'data-[loading=true]:opacity-50'
)}
value={value}
rows={1}
placeholder={tString(language, 'ai_chat_input_placeholder')}
onChange={handleInput}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.currentTarget.blur();
return;
}
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
event.preventDefault();
event.currentTarget.style.height = 'auto';
onSubmit(value);
}
}}
/>
{!disabled ? (
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
<HoverCardRoot>
<HoverCard <HoverCard
className="max-w-xs bg-tint p-2 text-sm text-tint" className="max-w-xs bg-tint p-2 text-sm text-tint"
arrow={{ className: 'fill-tint-3' }} arrow={{ className: 'fill-tint-3' }}
@@ -99,8 +133,7 @@ export function AIChatInput(props: {
</div> </div>
</HoverCard> </HoverCard>
<HoverCardTrigger> <HoverCardTrigger>
{/* Negative margin to compensate for Input's padding, so the badge appears flush with the cursor */} <div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
<div className="-ml-1 flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none"> <span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
{t(language, 'ai_chat_context_badge')} {t(language, 'ai_chat_context_badge')}
</span>{' '} </span>{' '}
@@ -111,7 +144,14 @@ export function AIChatInput(props: {
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
</HoverCardRoot> </HoverCardRoot>
} <Button
label={tString(language, 'send')}
size="medium"
className="ml-auto"
disabled={disabled || !value.trim()}
onClick={() => onSubmit(value)}
/> />
</div>
</div>
); );
} }
@@ -2,6 +2,7 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate'; import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api'; import { AIMessageRole } from '@gitbook/api';
import type React from 'react';
import type { AIChatController, AIChatState } from '../AI'; import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations'; import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback'; import { AIResponseFeedback } from './AIResponseFeedback';
@@ -10,82 +11,45 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: { export function AIChatMessages(props: {
chat: AIChatState; chat: AIChatState;
chatController: AIChatController; chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
}) { }) {
const { chat, chatController } = props; const { chat, chatController, lastUserMessageRef } = props;
// Group messages: user messages start a new group, all following messages until next user message belong to that group return (
type Message = (typeof chat.messages)[0]; <>
type MessageGroup = { message: Message; originalIndex: number }; {chat.messages.map((message, index) => {
const messageGroups: Array<Array<MessageGroup>> = []; const isLastMessage = index === chat.messages.length - 1;
let currentGroup: Array<MessageGroup> = []; const isLastUserMessage =
message.role === AIMessageRole.User &&
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
chat.messages.forEach((message, index) => {
if (message.role === AIMessageRole.User) {
// Start a new group
if (currentGroup.length > 0) {
messageGroups.push(currentGroup);
}
currentGroup = [{ message, originalIndex: index }];
} else {
// Add to current group
currentGroup.push({ message, originalIndex: index });
}
});
// Add the last group if it exists
if (currentGroup.length > 0) {
messageGroups.push(currentGroup);
}
return messageGroups.map((group, groupIndex) => {
const isLastGroup = group === messageGroups[messageGroups.length - 1];
return ( return (
<div <div
key={groupIndex} ref={isLastUserMessage ? lastUserMessageRef : undefined}
id={`message-group-${groupIndex}`} data-testid="ai-chat-message"
className={tcls(
'flex flex-col gap-2 pt-2',
isLastGroup ? 'shrink-0 basis-full' : '',
'transition-discrete'
)}
style={{ animationDelay: '.2s' }}
>
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
return (
<div
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
? 'ai-chat-message-user'
: 'ai-chat-message-assistant'
}
id={`message-${originalIndex}`}
className={tcls( className={tcls(
message.content ? 'animate-fade-in-slow' : '',
'shrink-0',
'last:min-h-[calc(100%-5rem)]',
'scroll-mt-36',
'lg:scroll-mt-0',
'flex flex-col gap-6', 'flex flex-col gap-6',
'break-words', 'break-words',
'group/message', 'group/message',
'animate-blur-in-slow',
isLastMessage ? 'basis-full' : '',
message.role === AIMessageRole.User 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' ? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong' : 'text-tint-strong'
)} )}
style={{ style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`, animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}} }}
key={index}
> >
{message.content} {message.content ? message.content : null}
{isLastMessage && message.role === AIMessageRole.Assistant ? ( {isLastMessage && chat.loading ? (
<div <div className="flex w-full animate-fade-in-slow flex-col gap-2">
className={tcls( {!message.content ? <HoldMessage /> : null}
'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 /> <LoadingSkeleton />
</div> </div>
) : null} ) : null}
@@ -113,30 +77,21 @@ export function AIChatMessages(props: {
</div> </div>
); );
})} })}
</div> </>
); );
});
} }
export function HoldMessage({ function HoldMessage() {
breakLines = false,
className,
}: { breakLines?: boolean; className?: string }) {
const language = useLanguage(); const language = useLanguage();
return ( return (
<div <div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
className={tcls(
'animate-[heightIn_.5s_5s_ease_both] overflow-hidden py-2 text-tint-subtle [&.hidden]:animate-[heightOut_1s_ease-in_both]',
className
)}
>
{tString(language, 'ai_chat_hold_message_1') {tString(language, 'ai_chat_hold_message_1')
.split(' ') .split(' ')
.map((word, index) => ( .map((word, index) => (
<span <span
key={index} key={index}
className="animate-blur-in-slow" className="animate-fade-in-slow"
style={{ style={{
animationDelay: `${5000 + index * 200}ms`, animationDelay: `${5000 + index * 200}ms`,
}} }}
@@ -144,13 +99,12 @@ export function HoldMessage({
{word}{' '} {word}{' '}
</span> </span>
))} ))}
{breakLines ? <br /> : null}
{tString(language, 'ai_chat_hold_message_2') {tString(language, 'ai_chat_hold_message_2')
.split(' ') .split(' ')
.map((word, index) => ( .map((word, index) => (
<span <span
key={index} key={index}
className="animate-blur-in-slow" className="animate-fade-in-slow"
style={{ style={{
animationDelay: `${10000 + index * 200}ms`, animationDelay: `${10000 + index * 200}ms`,
}} }}
@@ -168,9 +122,9 @@ function LoadingSkeleton() {
{Array.from({ length: 7 }).map((_, index) => ( {Array.from({ length: 7 }).map((_, index) => (
<div <div
key={index} key={index}
className="h-4 animate-[blurIn_500ms_ease-out_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2" className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{ style={{
width: `calc(${(4 - (index % 4)) * 8 + 14}% - 4px)`, width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`, animationDelay: `${index * 0.1}s`,
}} }}
/> />
@@ -7,27 +7,25 @@ export default function AIChatSuggestedQuestions(props: {
suggestions?: string[]; suggestions?: string[];
}) { }) {
const language = useLanguage(); const language = useLanguage();
const { chatController, suggestions: _suggestions } = props; const {
chatController,
const suggestions = suggestions = [
_suggestions && _suggestions.length > 0
? _suggestions
: [
tString(language, 'ai_chat_suggested_questions_about_this_page'), tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_read_next'), tString(language, 'ai_chat_suggested_questions_read_next'),
tString(language, 'ai_chat_suggested_questions_example'), tString(language, 'ai_chat_suggested_questions_example'),
]; ],
} = props;
return ( return (
<div className="flex flex-col items-start gap-2 self-start"> <div className="flex flex-col items-center gap-2">
{suggestions.map((question, index) => ( {suggestions.map((question, index) => (
<Button <Button
key={question} key={question}
variant="blank" variant="secondary"
size="medium" size="medium"
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 px-3 py-1.5 hover:bg-primary-hover" className="max-w-full animate-[present_500ms_both] whitespace-normal"
style={{ style={{
animationDelay: `${1000 + index * 100}ms`, animationDelay: `${800 + index * 100}ms`,
}} }}
onClick={() => chatController.postMessage({ message: question })} onClick={() => chatController.postMessage({ message: question })}
> >
@@ -29,7 +29,7 @@ export function AIChatToolConfirmations(props: {
); );
return ( return (
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden"> <div className="flex w-full flex-wrap justify-end gap-2">
{chat.pendingTools.map((tool, index) => ( {chat.pendingTools.map((tool, index) => (
<div <div
className="flex w-full animate-present-slow flex-col gap-1" className="flex w-full animate-present-slow flex-col gap-1"
@@ -43,7 +43,7 @@ export function AIChatToolConfirmations(props: {
tabIndex={index} tabIndex={index}
label={tool.label} label={tool.label}
className="w-full justify-center" className="w-full justify-center"
size={index === 0 ? 'large' : 'medium'} size={index === 0 ? 'default' : 'medium'}
variant={index === 0 ? 'primary' : 'secondary'} variant={index === 0 ? 'primary' : 'secondary'}
icon={tool.icon} icon={tool.icon}
/> />
@@ -24,9 +24,7 @@ export function AIResponseFeedback(props: {
}; };
return ( return (
<div <div className={tcls('flex h-fit items-center', className)}>
className={tcls('flex h-fit animate-[heightIn_.5s_ease_both] items-center', className)}
>
<Button <Button
icon="thumbs-up" icon="thumbs-up"
iconOnly iconOnly
@@ -37,7 +35,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0' rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
)} )}
size="medium" size="medium"
style={{ animationDelay: '.5s', animationDuration: '.5s' }} style={{ animationDuration: '.5s' }}
onClick={() => handleRating(1)} onClick={() => handleRating(1)}
disabled={rating !== null} disabled={rating !== null}
active={rating === 1} active={rating === 1}
@@ -53,7 +51,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0' rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
)} )}
size="medium" size="medium"
style={{ animationDelay: '.7s', animationDuration: '.5s' }} style={{ animationDelay: '.2s', animationDuration: '.5s' }}
onClick={() => handleRating(-1)} onClick={() => handleRating(-1)}
disabled={rating !== null} disabled={rating !== null}
active={rating === -1} active={rating === -1}
@@ -61,8 +59,8 @@ export function AIResponseFeedback(props: {
/> />
{rating !== null ? ( {rating !== null ? (
<span <span
className="ml-2 animate-blur-in-slow text-tint-subtle" className="ml-2 animate-fade-in-slow text-tint-subtle"
style={{ animationDelay: '.5s' }} style={{ animationDelay: '.3s' }}
> >
{t(language, 'was_this_helpful_thank_you')} {t(language, 'was_this_helpful_thank_you')}
</span> </span>
@@ -15,20 +15,15 @@ export function AIChatFollowupSuggestions(props: {
} }
return ( return (
<div className="flex grow flex-col"> <div className="mt-auto flex w-full flex-wrap justify-end gap-2">
<div
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
data-testid="ai-chat-followup-suggestions"
>
{chat.followUpSuggestions.map((suggestion, index) => ( {chat.followUpSuggestions.map((suggestion, index) => (
<Button <Button
data-testid="ai-chat-followup-suggestion"
key={index} key={index}
onClick={() => { onClick={() => {
chatController.postMessage({ message: suggestion }); chatController.postMessage({ message: suggestion });
}} }}
label={suggestion} label={suggestion}
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 px-3 py-1.5 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover" className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
size="medium" size="medium"
variant="blank" variant="blank"
style={{ style={{
@@ -37,6 +32,5 @@ export function AIChatFollowupSuggestions(props: {
/> />
))} ))}
</div> </div>
</div>
); );
} }
@@ -167,10 +167,10 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
icon="comment" icon="comment"
/> />
{/* Open published/live site */} {/* Open production site */}
{site.urls.published ? ( {site.urls.published ? (
<ToolbarButton <ToolbarButton
title="Open live site" title="Open production site"
href={getToolbarHref({ href={getToolbarHref({
href: site.urls.published, href: site.urls.published,
siteId: site.id, siteId: site.id,
@@ -249,7 +249,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
/> />
{site.urls.published ? ( {site.urls.published ? (
<ToolbarButton <ToolbarButton
title="Open live site" title="Open production site"
href={getToolbarHref({ href={getToolbarHref({
href: site.urls.published, href: site.urls.published,
siteId: site.id, siteId: site.id,
@@ -35,7 +35,6 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
} }
setOpen(false); setOpen(false);
}; };
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu); useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll // Close arc menu on scroll
@@ -6,7 +6,7 @@ import {
useReducedMotion, useReducedMotion,
useSpring, useSpring,
} from 'motion/react'; } from 'motion/react';
import React, { isValidElement } from 'react'; import React from 'react';
import { AnimatedLogo } from './AnimatedLogo'; import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext'; import { useToolbarControls } from './ToolbarControlsContext';
@@ -291,10 +291,6 @@ function ToolbarButtonWrapper(props: {
x: springX, x: springX,
}; };
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
return null;
}
return React.cloneElement(child, { return React.cloneElement(child, {
motionValues, motionValues,
}); });
@@ -136,7 +136,7 @@ const calculateSpacing = (
export function useMagnificationEffect(props: { export function useMagnificationEffect(props: {
childrenCount: number; childrenCount: number;
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement>;
config?: MagnificationConfig; config?: MagnificationConfig;
}) { }) {
const { childrenCount, containerRef, config } = props; const { childrenCount, containerRef, config } = props;
@@ -90,6 +90,7 @@ export function AnnouncementBanner(props: {
icon="close" icon="close"
label={tString(language, 'close')} label={tString(language, 'close')}
variant="blank" variant="blank"
size="default"
onClick={dismissAnnouncement} onClick={dismissAnnouncement}
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`} className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
/> />
@@ -61,11 +61,10 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'lg:chat-open:mr-80', 'lg:chat-open:mr-80',
'xl:chat-open:mr-100', 'xl:chat-open:mr-100',
'transition-all', 'transition-all',
'duration-300', 'duration-300'
'text-sm'
)} )}
> >
<p id={describedById}> <p id={describedById} className={tcls('text-sm')}>
{t( {t(
language, language,
'cookies_prompt', 'cookies_prompt',
@@ -82,10 +81,10 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => setShow(false)} onClick={() => setShow(false)}
className={tcls('absolute', 'top-2', 'right-2', 'hover:bg-tint-hover')} className={tcls('absolute', 'top-2', 'right-2', 'hover:bg-tint-hover')}
/> />
<div className="mt-3 flex flex-row gap-2 text-xs"> <div className={tcls('mt-3', 'flex', 'flex-row', 'gap-2')}>
<Button <Button
variant="primary" variant="primary"
size="medium" size="small"
aria-label={tString(language, 'cookies_accept')} aria-label={tString(language, 'cookies_accept')}
onClick={() => { onClick={() => {
onUpdateState(true); onUpdateState(true);
@@ -94,7 +93,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
/> />
<Button <Button
variant="secondary" variant="secondary"
size="medium" size="small"
aria-label={tString(language, 'cookies_reject')} aria-label={tString(language, 'cookies_reject')}
onClick={() => { onClick={() => {
onUpdateState(false); onUpdateState(false);
@@ -27,7 +27,7 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
ancestorBlocks={[]} ancestorBlocks={[]}
context={context} context={context}
nodes={fragment.nodes} nodes={fragment.nodes}
style={['contents']} style={['space-y-4']}
/> />
} }
> >
@@ -1,30 +1,84 @@
'use client'; 'use client';
import * as Popover from '@radix-ui/react-popover';
import type React from 'react'; import type React from 'react';
import { Tooltip } from '@/components/primitives'; import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) { export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
const { children, body } = props; const { children, body } = props;
const language = useLanguage();
return ( return (
<Tooltip <Popover.Root>
label={body} <Popover.Trigger asChild>
contentProps={{ <button
role: 'definition',
}}
className="bg-tint-base px-4 py-3 text-sm text-tint-strong shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1"
arrow={true}
arrowProps={{ className: 'fill-tint-1' }}
>
<dfn
data-testid="annotation-button" data-testid="annotation-button"
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2" aria-label={tString(language, 'annotation_button_label')}
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip className={tcls(
tabIndex={0} 'decoration-dotted',
'decoration-1',
'underline',
'underline-offset-2'
)}
> >
{children} {children}
</dfn> </button>
</Tooltip> </Popover.Trigger>
<Popover.Portal>
<Popover.Content
className={tcls(
'text-sm',
'max-w-[280px]',
'bg-tint',
'ring-1',
'ring-tint',
'rounded-sm',
'shadow-1xs',
'shadow-tint-12/1',
'dark:shadow-tint-1/2',
'p-3',
'[&_p]:leading-snug',
'-outline-offset-2',
'outline-2',
'outline-primary/8',
'z-20'
)}
sideOffset={4}
>
{body}
<Popover.Arrow asChild>
<svg
viewBox="0 0 8 5"
className={tcls(
'relative',
'z-2',
'fill-tint-3', // Same as bg-tint
'stroke-tint-7', // Same as ring-tint
'[paint-order:stroke_fill]'
)}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clipAnnotation)">
<path
d="M0 0L4 4L8 0"
strokeWidth="2"
strokeLinecap="round"
stroke="inherit"
fill="inherit"
/>
</g>
<defs>
<clipPath id="clipAnnotation">
<rect width="8" height="5" fill="white" />
</clipPath>
</defs>
</svg>
</Popover.Arrow>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
); );
} }
@@ -6,7 +6,6 @@ import {
SkeletonImage, SkeletonImage,
SkeletonParagraph, SkeletonParagraph,
SkeletonSmall, SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives'; } from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind'; import type { ClassValue } from '@/lib/tailwind';
@@ -35,8 +34,6 @@ import { Stepper } from './Stepper';
import { StepperStep } from './StepperStep'; import { StepperStep } from './StepperStep';
import { Table } from './Table'; import { Table } from './Table';
import { Tabs } from './Tabs'; import { Tabs } from './Tabs';
import { Update } from './Update';
import { Updates } from './Updates';
export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps { export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps {
block: Block; block: Block;
@@ -108,10 +105,6 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <Stepper {...props} block={block} />; return <Stepper {...props} block={block} />;
case 'stepper-step': case 'stepper-step':
return <StepperStep {...props} block={block} />; return <StepperStep {...props} block={block} />;
case 'updates':
return <Updates {...props} block={block} />;
case 'update':
return <Update {...props} block={block} />;
case 'if': case 'if':
// If block should be processed by the API. // If block should be processed by the API.
return null; return null;
@@ -172,13 +165,10 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'images': case 'images':
case 'drawing': case 'drawing':
return <SkeletonImage id={id} style={style} />; return <SkeletonImage id={id} style={style} />;
case 'updates':
return <SkeletonUpdate id={id} style={style} />;
case 'image': case 'image':
case 'code-line': case 'code-line':
case 'tabs-item': case 'tabs-item':
case 'column': case 'column':
case 'update':
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`); throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
default: default:
return nullIfNever(block); return nullIfNever(block);
@@ -1,18 +1,9 @@
import { import { type DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api';
type ContentRef,
type DocumentBlockContentRef,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { Card, type CardProps } from '@/components/primitives'; import { Card } from '@/components/primitives';
import { import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
} from '@/lib/references';
import type { BlockProps } from './Block'; import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) { export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props; const { block, context, style } = props;
@@ -25,45 +16,51 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
: null; : null;
if (!resolved) { if (!resolved) {
const fallback = resolveContentRefFallback(block.data.ref);
if (!fallback) {
return null; return null;
} }
return (
<NotFoundRefHoverCard context={context}> const isContentInOtherSpace =
<BlockContentRefCard context.contentContext?.space &&
contentRef={block.data.ref} 'space' in block.data.ref &&
resolved={fallback} context.contentContext.space.id !== block.data.ref.space;
style={style} const kind = block?.data?.ref?.kind;
/> if ((resolved.active && kind === 'space') || isContentInOtherSpace) {
</NotFoundRefHoverCard> return <SpaceRefCard {...props} resolved={resolved} />;
);
} }
return <BlockContentRefCard contentRef={block.data.ref} resolved={resolved} style={style} />;
}
function BlockContentRefCard(
props: {
resolved: ResolvedContentRef;
contentRef: ContentRef;
} & Omit<CardProps, 'href' | 'title'>
) {
const { ref, resolved, contentRef, ...rest } = props;
return ( return (
<Card <Card
ref={ref}
leadingIcon={resolved.icon ? resolved.icon : null} leadingIcon={resolved.icon ? resolved.icon : null}
href={resolved.href} href={resolved.href}
title={resolved.text} title={resolved.text}
style={style}
insights={{ insights={{
type: 'link_click', type: 'link_click',
link: { link: {
target: contentRef, target: block.data.ref,
position: SiteInsightsLinkPosition.Content, position: SiteInsightsLinkPosition.Content,
}, },
}} }}
{...rest} />
);
}
async function SpaceRefCard(
props: { resolved: ResolvedContentRef } & BlockProps<DocumentBlockContentRef>
) {
const { context, style, resolved } = props;
const spaceId = context.contentContext?.space.id;
if (!spaceId) {
return null;
}
return (
<Card
href={resolved.href}
title={resolved.text}
postTitle={resolved.subText}
style={style}
/> />
); );
} }
@@ -51,13 +51,11 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
* Ideally we'd rely on the block type to determine if it can be full width, but * Ideally we'd rely on the block type to determine if it can be full width, but
* the block's `fullWidth` property does not differentiate between `undefined` and `false`. * the block's `fullWidth` property does not differentiate between `undefined` and `false`.
* So instead we hardcode a list of blocks that can be full width. */ * So instead we hardcode a list of blocks that can be full width. */
const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [ const FULL_WIDTH_BLOCKS = [
'table', 'table',
'tabs', 'tabs',
'integration', 'integration',
'openapi-operation', 'openapi',
'openapi-schemas',
'openapi-webhook',
'images', 'images',
'embed', 'embed',
'columns', 'columns',
@@ -66,8 +64,6 @@ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
'hint', 'hint',
]; ];
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
/** /**
* Renders a list of blocks without a wrapper element. * Renders a list of blocks without a wrapper element.
*/ */
@@ -89,11 +85,10 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`} key={node.key || `${node.type}-${index}`}
block={node} block={node}
style={[ style={[
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6', 'mx-auto page-width-wide:mx-0 w-full decoration-primary/6 print:break-inside-avoid',
node.data && 'fullWidth' in node.data && node.data.fullWidth node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl' ? 'max-w-screen-xl'
: 'max-w-3xl', : 'max-w-3xl',
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full', FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle, blockStyle,
]} ]}
@@ -1,14 +1,11 @@
'use client'; 'use client';
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive'; import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener'; import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener'; import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts'; import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression'; import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
@@ -19,17 +16,14 @@ import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[]; inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables; inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
}; };
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
/** /**
* Render a code-block client-side by loading the highlighter asynchronously. * Render a code-block client-side by loading the highlighter asynchronously.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting. * It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/ */
export function ClientCodeBlock(props: ClientBlockProps) { export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables } = props; const { block, style, inlines, inlineExprVariables } = props;
const blockRef = useRef<HTMLDivElement>(null); const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false); const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
@@ -119,84 +113,13 @@ export function ClientCodeBlock(props: ClientBlockProps) {
setLines(null); setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]); }, [isInViewport, block, inlines, evaluateInlineExpression]);
const expandable = block.data.expandable; return (
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
expandable && mode !== 'print' && numberOfLinesOfCode > collapsedLineCount
);
const codeBlockBodyId = useId();
const renderer = (
<CodeBlockRenderer <CodeBlockRenderer
ref={blockRef} ref={blockRef}
aria-busy={highlighting} aria-busy={highlighting}
block={block} block={block}
style={style} style={style}
lines={lines ?? plainLines} lines={lines ?? plainLines}
id={codeBlockBodyId}
/> />
); );
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
>
{renderer}
</CodeBlockExpandable>
) : (
renderer
);
}
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
collapsedLineCount: number;
controls?: string;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls(
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
)}
style={
{
'--line-count': collapsedLineCount,
'--line-height': '1.25rem',
} as React.CSSProperties
}
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<Button
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
size="xsmall"
variant="blank"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
aria-expanded={isExpanded}
aria-controls={controls}
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
</Button>
</div>
</div>
);
} }
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body }; return { inline, body };
}); });
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) { if (!isEstimatedOffscreen && !hasInlineExpression) {
// In v2, we render the code block server-side // In v2, we render the code block server-side
const lines = await highlight(block, richInlines); const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />; return <CodeBlockRenderer block={block} style={style} lines={lines} />;
@@ -73,7 +73,6 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
style={style} style={style}
inlines={richInlines} inlines={richInlines}
inlineExprVariables={variables} inlineExprVariables={variables}
mode={context.mode}
/> />
</React.Suspense> </React.Suspense>
); );
@@ -12,7 +12,6 @@ import type { HighlightLine, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[]; lines: HighlightLine[];
'aria-busy'?: boolean; 'aria-busy'?: boolean;
id?: string;
}; };
/** /**
@@ -24,12 +23,11 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) { ) {
const { block, style, lines, 'aria-busy': ariaBusy } = props; const { block, style, lines, 'aria-busy': ariaBusy } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap'; const withWrap = block.data.overflow === 'wrap';
const title = block.data.title; const title = block.data.title;
const id = useId();
const codeId = props.id || id;
return ( return (
<div <div
ref={ref} ref={ref}
@@ -44,7 +42,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null} ) : null}
</div> </div>
<CopyCodeButton <CopyCodeButton
codeId={codeId} codeId={id}
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11" style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/> />
<pre <pre
@@ -55,7 +53,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
)} )}
> >
<code <code
id={codeId} id={id}
className={tcls( className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap', 'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap' withWrap && 'whitespace-pre-wrap'
@@ -80,7 +80,6 @@ export function Column(props: {
<div <div
className={tcls( className={tcls(
'flex flex-col', 'flex flex-col',
'@container/column',
(verticalAlignment === VerticalAlignment.Top || !verticalAlignment) && (verticalAlignment === VerticalAlignment.Top || !verticalAlignment) &&
'justify-start', 'justify-start',
verticalAlignment === VerticalAlignment.Middle && 'justify-center', verticalAlignment === VerticalAlignment.Middle && 'justify-center',
@@ -31,7 +31,6 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
alt="Drawing" alt="Drawing"
sizes={imageBlockSizes} sizes={imageBlockSizes}
zoom zoom
loading="lazy"
/> />
</Caption> </Caption>
); );
@@ -60,7 +60,6 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
sources={{ light: { src: embed.icon } }} sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]} sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer} resize={context.contentContext.imageResizer}
loading="lazy"
/> />
) : null ) : null
} }
@@ -17,34 +17,29 @@ export function Details(props: {
}) { }) {
const { children, id, className } = props; const { children, id, className } = props;
const ref = React.useRef<HTMLDetailsElement>(null); const detailsRef = React.useRef<HTMLDetailsElement>(null);
const [openFromHash, setOpenFromHash] = React.useState(false); const [openFromHash, setOpenFromHash] = React.useState(false);
const hash = useHash(); const hash = useHash();
/** /**
* Open the details element if the url hash refers to the id of the details element * Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element. * or the id of some element contained within the details element.
*/ */
React.useEffect(() => { React.useEffect(() => {
if (!hash || !ref.current) { if (!hash || !detailsRef.current) {
return; return;
} }
if (hash === id) { if (hash === id) {
setOpenFromHash(true); setOpenFromHash(true);
return;
} }
const activeElement = document.getElementById(hash); const activeElement = document.getElementById(hash);
const isOpen = Boolean(activeElement && ref.current.contains(activeElement)); setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
setOpenFromHash(isOpen);
}, [hash, id]); }, [hash, id]);
return ( return (
<details <details
ref={ref} ref={detailsRef}
id={id} id={id}
open={props.open || openFromHash} open={props.open || openFromHash}
className={tcls( className={tcls(
@@ -25,11 +25,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id = context.getId ? context.getId(id) : id; id = context.getId ? context.getId(id) : id;
return ( return (
<Details <Details id={id} open={context.mode === 'print'} className={style}>
id={id}
open={context.mode === 'print'}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary <summary
className={tcls( className={tcls(
'cursor-pointer', 'cursor-pointer',
@@ -51,7 +47,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
className={tcls( className={tcls(
'inline-block', 'inline-block',
'size-3', 'size-3',
'mr-3', 'mr-2',
'mb-1', 'mb-1',
'transition-transform', 'transition-transform',
'shrink-0', 'shrink-0',
@@ -96,7 +92,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
document={document} document={document}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
context={context} context={context}
style="space-y-4 px-10 pb-5" style={['px-10', 'pb-5', 'space-y-4']}
/> />
</Details> </Details>
); );

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