mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50cafe9ec6 | |||
| 2f05549c72 | |||
| 2da0ccf4ba | |||
| 9e062b10d3 | |||
| 74f89316d9 | |||
| 9e1d7b296b | |||
| b49caafa9c | |||
| f372eec393 | |||
| 65ae941687 | |||
| 75b8efa155 | |||
| 34b0c67c91 | |||
| e0ace99707 | |||
| 3553db0989 | |||
| 5c14948cf3 | |||
| 8e4e2a2333 | |||
| 0f4c53cfbd | |||
| c156e946b0 | |||
| 461e15fd00 | |||
| 2989f79708 | |||
| 344842f5ce | |||
| c51076efc4 | |||
| 38e34dcce3 | |||
| 04a4175678 | |||
| 3e40b4db50 | |||
| 854e612e5d | |||
| 27a461be9d | |||
| 3d83dadb12 | |||
| 8dc5c18b9f | |||
| e4756c23a7 | |||
| 79ad7bcb59 | |||
| 5b0917a13a | |||
| 6d6f4ef6dd | |||
| fa69a64a2b | |||
| d1a85d1f91 | |||
| 05bf9d7a59 | |||
| 2e0b3370c8 | |||
| 10995e09ca | |||
| e7cc3ff61f | |||
| 59d259074b | |||
| 96c4ae4f2d | |||
| 64f3596df2 | |||
| 27d6c3af39 | |||
| 1de4eb7973 | |||
| df1966df25 | |||
| 1001aaf94e | |||
| 155e448c8f | |||
| ca2a94ee1a | |||
| a512c90bda | |||
| 9cbefa89c7 | |||
| c8bba7bfe1 | |||
| 4e04db8a1b | |||
| b45feafeff | |||
| 3876f7081e | |||
| b4a021a192 | |||
| 1d92dc1350 | |||
| e4ba35fdae | |||
| 35dcaca971 | |||
| 2aa4f2e0b8 | |||
| 6d56df7c10 | |||
| 4534ea83c3 | |||
| 9022d87ec7 | |||
| a49781e200 | |||
| 065a5e6e55 | |||
| e27986b17a | |||
| 8606f222c9 | |||
| 2c3066e3ae | |||
| 55c0b03a41 | |||
| 3e548e4186 | |||
| 3aea302648 | |||
| 1d1f0a7c6a | |||
| 590729cce5 | |||
| b554d7d13f | |||
| 9568b8a7c8 | |||
| 683bb8f634 | |||
| 7d6a31627f | |||
| c31e5648f5 | |||
| 0c01832684 | |||
| 6b1c9fef86 | |||
| cc976995cc | |||
| ebbb476589 | |||
| 89655cb428 | |||
| 3676d64bdc | |||
| f682df1e01 | |||
| 6c9893945d | |||
| a2fb627ae1 | |||
| f9f80117f0 | |||
| 63bf4dbb16 | |||
| b3fa503aff | |||
| 0ae7cfd790 | |||
| 8fdc5c89dc | |||
| 758b34d46b | |||
| 1b0613e482 | |||
| 3164f1a99d | |||
| 6b588af07d | |||
| d436b04ff4 | |||
| d159788d0b | |||
| 3355960d95 | |||
| c32d4494f2 | |||
| 1dcf4df51f | |||
| 3c0e49d56b | |||
| c35d0e8954 | |||
| c0d73d2f76 | |||
| 454175ab86 | |||
| 6feb8fcd6a | |||
| b8d3cb7ee5 | |||
| 8ce73229c3 | |||
| 8eae6e970b | |||
| a48b886dfd | |||
| 17de0838c5 | |||
| baf3496980 | |||
| ae33c09aa7 | |||
| 7ff69c3b72 | |||
| 6142d6bf80 | |||
| 75543b140f | |||
| 511f1cfbe5 | |||
| 46fa9d954c | |||
| 5e1ed5358e | |||
| 295f03db49 | |||
| bdde3929fc | |||
| 4f4210d24a | |||
| c038ec8f81 | |||
| 75b52eac28 | |||
| ad4d07dc06 | |||
| 75086740f8 | |||
| eea8f1e00f | |||
| 4f9abfb6f2 | |||
| 252afceab0 | |||
| bef191660a | |||
| 145625142d | |||
| f70a2aa310 | |||
| 56e46ce8ab | |||
| 754cc11e0b | |||
| 3551d147e9 | |||
| 162cfe4163 | |||
| 6815dd197e | |||
| d74f8a7098 | |||
| 1e53f46521 | |||
| 1a6216df4f | |||
| a629900324 | |||
| a4c33997fe | |||
| badf3a92f6 | |||
| e0fb9ac3ca | |||
| f3e40410c4 | |||
| 8e99871004 | |||
| 1e4e54e752 | |||
| 50c1be3447 | |||
| 319a1e536c | |||
| 50cb0c6e15 | |||
| ceb32b1a2e | |||
| f8c54b4d34 | |||
| c06ad70f8a | |||
| 0ef5dc8538 | |||
| 28adc9b5f1 | |||
| 912d1693f5 | |||
| c23d4ef331 | |||
| c21693fe50 | |||
| c06b3dd60d | |||
| f173e014db | |||
| 1b458c436e | |||
| b42b0b3910 | |||
| 2fc21272ff | |||
| 379d486488 | |||
| cba583a31c | |||
| 7a504b7cc5 | |||
| 8ec5fd0986 | |||
| 529f940a45 | |||
| 8a3c15911f | |||
| d7948e34b2 | |||
| 9f42211993 | |||
| 65e62307bf | |||
| 469b332da2 | |||
| e434442f1e | |||
| 9344431bfc | |||
| 622ecbec1d | |||
| fa1382b289 | |||
| b8388e5a2a | |||
| 2bce12798f | |||
| f89e889d18 | |||
| 2b8a2d24b6 | |||
| b85eccdff5 | |||
| d924e655c0 | |||
| b932e4e2e9 | |||
| ca4a3e8c9a | |||
| b498521196 | |||
| 8a8cf2025d | |||
| ed7d47d9b8 | |||
| b5494cc04b | |||
| 229f2ba9d5 | |||
| 6da36558f6 | |||
| ca71c1fad6 | |||
| 262afa357b |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/openapi-parser': patch
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI basic auth placeholder
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix links to other spaces and root page in embeddable view.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Add a RSS feed to all pages with update blocks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Remove hidden site spaces from navigation
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix hidden section not found
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve OpenAPI circular references
|
||||
@@ -54,10 +54,9 @@ After forking this repository, you'll want to [create a branch](https://docs.git
|
||||
#### 3. Install dependencies and run the project locally
|
||||
|
||||
##### Prerequisites:
|
||||
- Node.js (Version: >=20.6)
|
||||
- Use `nvm` for easy Node management
|
||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||
|
||||
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
|
||||
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
|
||||
|
||||
##### Setup steps:
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||
|
||||
@@ -49,7 +49,7 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
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
|
||||
|
||||
@@ -61,7 +61,7 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||
|
||||
@@ -71,7 +71,7 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
|
||||
|
||||
@@ -81,7 +81,7 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
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 }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '4.10.0'
|
||||
wranglerVersion: '4.43.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- changeset-release/main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -41,7 +42,7 @@ jobs:
|
||||
name: Build (Open Source)
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ''
|
||||
BUN_NPM_TOKEN: ''
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
@@ -182,9 +182,9 @@ jobs:
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
pagespeed-testing-v2:
|
||||
browserless-testing-v2-vercel:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing v1
|
||||
name: Browserless Testing v2 (Vercel)
|
||||
needs: deploy-v2-vercel
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -195,8 +195,26 @@ jobs:
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run pagespeed tests
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
||||
- name: Run tests
|
||||
run: cd ./packages/gitbook && bun e2e-browserless
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
# browserless-testing-v2-cloudflare:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Browserless Testing v2 (Cloudflare)
|
||||
# needs: deploy-v2-cloudflare
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# - name: Setup Bun
|
||||
# uses: ./.github/composite/setup-bun
|
||||
# - name: Install dependencies
|
||||
# run: bun install --frozen-lockfile
|
||||
# env:
|
||||
# PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
# - name: Run tests
|
||||
# run: cd ./packages/gitbook && bun e2e-browserless
|
||||
# env:
|
||||
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
|
||||
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (production)
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (staging)
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -14,29 +14,38 @@ jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||
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
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
- name: Create Release Pull Request
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: npm run release
|
||||
version: npm run changeset-version
|
||||
publish: bun run publish-all-packages
|
||||
version: bun run changeset-version
|
||||
env:
|
||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
|
||||
@@ -25,3 +25,7 @@ yarn-error.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
# Bun pack artifacts
|
||||
packages/*/*.tgz
|
||||
|
||||
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (Version: >=20.6)
|
||||
- Node.js (Version: >= 22.3)
|
||||
- Use nvm for easy Node management
|
||||
- [Bun](https://bun.sh/) (Version: >=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
|
||||
|
||||
NPM_TOKEN_READONLY=xxx
|
||||
BUN_NPM_TOKEN=xxx
|
||||
```
|
||||
|
||||
and then reinstall dependencies.
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[install.scopes]
|
||||
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
||||
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
|
||||
|
||||
+29
-11
@@ -1,22 +1,27 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": "^22.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"turbo": "^2.5.0",
|
||||
"vercel": "^39.3.0"
|
||||
"@changesets/cli": "^2.29.7",
|
||||
"turbo": "^2.6.1",
|
||||
"vercel": "^39.4.2"
|
||||
},
|
||||
"packageManager": "bun@1.2.15",
|
||||
"packageManager": "bun@1.3.2",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev": "turbo run dev --concurrency 20",
|
||||
"build": "turbo run build",
|
||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||
"typecheck": "turbo run typecheck",
|
||||
@@ -26,16 +31,29 @@
|
||||
"e2e": "turbo run e2e",
|
||||
"e2e-customers": "turbo run e2e-customers",
|
||||
"changeset": "changeset",
|
||||
"changeset-version": "changeset version && bun run format",
|
||||
"release": "turbo run release && changeset publish",
|
||||
"changeset-version": "changeset version && bun run format && bun update",
|
||||
"publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.140.0",
|
||||
"bidc": "^0.0.2"
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.151.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",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
- Updated dependencies [295f03d]
|
||||
- @gitbook/icons@0.3.2
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -8,17 +8,29 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "tsdown",
|
||||
"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": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/GitbookIO/gitbook"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,22 +4,32 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.3.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.3.4",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"assert-never": "^1.2.1"
|
||||
"assert-never": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/GitbookIO/gitbook"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -4,18 +4,28 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.4.0",
|
||||
"sideEffects": false,
|
||||
"version": "0.4.3",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/GitbookIO/gitbook"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @gitbook/embed
|
||||
|
||||
## 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
- Updated dependencies [295f03d]
|
||||
- @gitbook/icons@0.3.2
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7508674: Fix bundling of packages
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+27
-10
@@ -5,27 +5,44 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js",
|
||||
"standalone": "./dist/standalone/index.js",
|
||||
"react": "./dist/react/index.js"
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"default": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
"react": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"react": "^19.0.0"
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"react": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "bun run build-lib && bun run build-standalone",
|
||||
"build-lib": "tsdown",
|
||||
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"clean": "rm -rf ./dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/GitbookIO/gitbook"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
GetFrameURLOptions,
|
||||
GitBookEmbeddableConfiguration,
|
||||
@@ -17,19 +19,19 @@ export type GitBookFrameProps = {
|
||||
export function GitBookFrame(props: GitBookFrameProps) {
|
||||
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||
|
||||
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const gitbook = useGitBook();
|
||||
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
|
||||
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
|
||||
|
||||
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (frameRef.current) {
|
||||
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
||||
}
|
||||
}, [gitbook]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
gitbookFrame?.configure({
|
||||
buttons,
|
||||
welcomeMessage,
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
--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;
|
||||
}
|
||||
@@ -120,7 +130,8 @@
|
||||
z-index: 9998;
|
||||
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))));
|
||||
background-color: var(--gitbook-widget-background-solid);
|
||||
background-color: var(--gitbook-widget-background-translucent);
|
||||
backdrop-filter: blur(48px);
|
||||
border: 1px solid var(--gitbook-widget-border-color);
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: 'src/index.ts',
|
||||
outDir: 'dist',
|
||||
},
|
||||
{
|
||||
entry: 'src/react/index.ts',
|
||||
outDir: 'dist/react',
|
||||
unbundle: true,
|
||||
},
|
||||
]);
|
||||
@@ -1,13 +1,25 @@
|
||||
# @gitbook/emoji-codepoints
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 57adb3e: Second release to fix publishing with changeset
|
||||
- 57adb3e: Second release to fix publishing with changeset
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5f8a8fe: Initial release
|
||||
- 5f8a8fe: Initial release
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitbook/emoji-codepoints",
|
||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"exports": "./dist/index.ts",
|
||||
"dependencies": {},
|
||||
"sideEffects": false,
|
||||
"devDependencies": {
|
||||
"emoji-assets": "^8.0.0"
|
||||
"emoji-assets": "^9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./build.ts",
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a629900: Add dev script for @gitbook/expr
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6da3655: Fix exports in gitbook/expr package.json
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+21
-11
@@ -1,37 +1,47 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.4",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
"assert-never": "^1.2.1"
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.20",
|
||||
"tsdown": "^0.15.0",
|
||||
"@tsconfig/strictest": "catalog:",
|
||||
"@tsconfig/node20": "catalog:",
|
||||
"@types/escodegen": "^0.0.10",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@babel/types": "^7.26.0",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/escodegen": "^0.0.10"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown --project tsconfig.build.json",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test",
|
||||
"clean": "rm -rf ./dist"
|
||||
"clean": "rm -rf ./dist",
|
||||
"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": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/GitbookIO/gitbook"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": ["./tsconfig.json"],
|
||||
"exclude": ["**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -4,28 +4,38 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"google-font-metadata": "^6.0.3",
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"google-font-metadata": "^6.0.7",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./bin/generate.js",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||
"unit": "bun test"
|
||||
"unit": "bun test",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
},
|
||||
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
||||
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"repository": {
|
||||
"url": "https://github.com/GitbookIO/gitbook"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
// Bun Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`getDefaultFont should return correct object for Latin text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should return correct object for Greek text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle mixed script text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different font weights: regular 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different font weights: bold 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different fonts: inter 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
|
||||
{
|
||||
"font": "Roboto",
|
||||
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
|
||||
"url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,5 +1,162 @@
|
||||
# gitbook
|
||||
|
||||
## 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
- Updated dependencies [bdde392]
|
||||
- Updated dependencies [295f03d]
|
||||
- @gitbook/react-contentkit@0.7.6
|
||||
- @gitbook/browser-types@0.1.1
|
||||
- @gitbook/cache-tags@0.3.2
|
||||
- @gitbook/colors@0.4.1
|
||||
- @gitbook/emoji-codepoints@0.2.1
|
||||
- @gitbook/expr@1.2.2
|
||||
- @gitbook/fonts@0.1.1
|
||||
- @gitbook/icons@0.3.2
|
||||
- @gitbook/openapi-parser@3.0.3
|
||||
- @gitbook/react-math@0.6.2
|
||||
- @gitbook/react-openapi@1.5.1
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ed7d47d: Show a toolbar for authenticated users with access to the current site.
|
||||
- 50c1be3: Move inline expression evaluation from API to GBO
|
||||
- 56e46ce: Add Italian translation
|
||||
- b932e4e: Scope search across sections and variants
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 162cfe4: Hide sections if only one is present
|
||||
- 1456251: Unify section display condition
|
||||
- 9344431: Fix missing geo data in site insights because of incorrect proxying of events.
|
||||
- eea8f1e: Enhance OpenAPI security scopes handling
|
||||
- 2b8a2d2: Add stable scroll gutter to search container
|
||||
- b498521: Fix robots.txt preventing favicon from being indexed.
|
||||
- ceb32b1: Redo search scope UI
|
||||
- 8e99871: Highlight discriminator properties in oneOf, allOf, anyOf objects
|
||||
- 2fc2127: Make search accessible
|
||||
- f3e4041: Adapt OpenAPI code samples to prefill API key using visitor data
|
||||
- 6815dd1: Fix markdownPageURL for PageActions
|
||||
- c06b3dd: Improve default site icon
|
||||
- cba583a: Use sitespace title instead of space title in search
|
||||
- e434442: Support nested site section groups
|
||||
- 469b332: Add support for inline expressions rendering with visitor data on GBO side
|
||||
- 0ef5dc8: Update to column width sizing
|
||||
- 1e4e54e: Fix rounded outline items
|
||||
- 379d486: Expose "Best match" when site has translations
|
||||
- c21693f: Improve screen reader accessibility for hints
|
||||
- badf3a9: Add alt text to inline image
|
||||
- c23d4ef: Fix crash for card cover defined without objectFit
|
||||
- d74f8a7: Improve wide page width layout
|
||||
- b8388e5: Add scrollcontainer component
|
||||
- b85eccd: Refactor section tabs
|
||||
- 1e53f46: Fix button content truncate
|
||||
- 7a504b7: Fix OpenAPI response dropdown & vertical overflow
|
||||
- a4c3399: Fix OpenAPI alternatives not showing
|
||||
- Updated dependencies [a629900]
|
||||
- Updated dependencies [eea8f1e]
|
||||
- Updated dependencies [8e99871]
|
||||
- Updated dependencies [f3e4041]
|
||||
- Updated dependencies [319a1e5]
|
||||
- Updated dependencies [529f940]
|
||||
- Updated dependencies [754cc11]
|
||||
- Updated dependencies [d7948e3]
|
||||
- Updated dependencies [a4c3399]
|
||||
- @gitbook/expr@1.2.1
|
||||
- @gitbook/react-openapi@1.5.0
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 262afa3: Expose a MCP server for the docs site under /~gitbook/mcp
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [262afa3]
|
||||
- @gitbook/icons@0.3.1
|
||||
- @gitbook/react-openapi@1.4.3
|
||||
- @gitbook/react-contentkit@0.7.5
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -62,11 +62,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.gmgn.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.spicychat.ai',
|
||||
contentBaseURL: 'https://docs.spicychat.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.spicychat.ai',
|
||||
// contentBaseURL: 'https://docs.spicychat.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.portainer.io',
|
||||
contentBaseURL: 'https://docs.portainer.io',
|
||||
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.gradient.network',
|
||||
contentBaseURL: 'https://docs.gradient.network',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.gradient.network',
|
||||
// contentBaseURL: 'https://docs.gradient.network',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
// {
|
||||
// name: 'mygate-network.gitbook.io',
|
||||
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
||||
@@ -172,11 +172,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.realapp.link',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.plaza.finance',
|
||||
contentBaseURL: 'https://docs.plaza.finance',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.publicai.io',
|
||||
contentBaseURL: 'https://docs.publicai.io',
|
||||
|
||||
@@ -33,9 +33,35 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
|
||||
|
||||
const 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[] = [
|
||||
{
|
||||
name: 'Search - AI Mode: None - Complete flow',
|
||||
@@ -52,7 +78,9 @@ const searchTestCases: Test[] = [
|
||||
|
||||
// Fill search input, expecting search results
|
||||
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();
|
||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||
@@ -97,9 +125,6 @@ const searchTestCases: Test[] = [
|
||||
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)',
|
||||
url: `${getCustomizationURL({
|
||||
@@ -107,16 +132,12 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Search,
|
||||
},
|
||||
})}&q=gitbook`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||
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',
|
||||
url: getCustomizationURL({
|
||||
@@ -124,25 +145,32 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||
|
||||
// Focus search input, expecting recommended questions
|
||||
await searchInput.focus();
|
||||
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
|
||||
// await expect(page.getByTestId('search-results')).toBeVisible();
|
||||
// const recommendedQuestions = await page
|
||||
// .getByTestId('search-recommended-question')
|
||||
// .all();
|
||||
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
await expect(page.getByTestId('search-results')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const recommendedQuestions = await page
|
||||
.getByTestId('search-recommended-question')
|
||||
.all();
|
||||
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||
|
||||
// Fill search input, expecting AI search option
|
||||
await searchInput.fill('What is gitbook?');
|
||||
await searchInput.fill(AI_PROMPT);
|
||||
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||
await expect(aiSearchResult).toBeVisible();
|
||||
await aiSearchResult.click();
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,11 +180,12 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
}),
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await page.keyboard.press('ControlOrMeta+I');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -171,6 +200,8 @@ const searchTestCases: Test[] = [
|
||||
await page.getByTestId('ai-chat-button').click();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -180,12 +211,13 @@ const searchTestCases: Test[] = [
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=`,
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
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')).toBeEmpty();
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIInitialState);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,17 +226,18 @@ const searchTestCases: Test[] = [
|
||||
ai: {
|
||||
mode: CustomizationAIMode.Assistant,
|
||||
},
|
||||
})}&ask=What+is+GitBook%3F`,
|
||||
screenshot: false,
|
||||
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||
await expect(page.getByTestId('ai-chat')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
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,
|
||||
});
|
||||
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
|
||||
'What is GitBook?'
|
||||
);
|
||||
// Override text content for visual consistency in screenshots
|
||||
await page.evaluate(overrideAIResponse);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -228,6 +261,58 @@ 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,
|
||||
{
|
||||
name: 'Not found',
|
||||
@@ -285,6 +370,37 @@ const testCases: TestsCase[] = [
|
||||
).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();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -727,7 +843,10 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/integrations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
|
||||
const mermaidIframe = page
|
||||
.locator('iframe[title*="mermaid"]')
|
||||
.first()
|
||||
.contentFrame();
|
||||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||
},
|
||||
@@ -793,6 +912,11 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/cards',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Updates',
|
||||
url: 'blocks/updates',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
@@ -851,7 +975,10 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With cover',
|
||||
url: 'page-options/page-with-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover for dark mode',
|
||||
@@ -866,12 +993,18 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With hero cover',
|
||||
url: 'page-options/page-with-hero-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover and no TOC',
|
||||
url: 'page-options/page-with-cover-and-no-toc',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
screenshot: {
|
||||
waitForTOCScrolling: false,
|
||||
},
|
||||
@@ -1454,10 +1587,7 @@ const testCases: TestsCase[] = [
|
||||
locale,
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,7 +143,10 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
||||
|
||||
export async function waitForCookiesDialog(page: Page) {
|
||||
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) {
|
||||
@@ -151,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
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.
|
||||
*/
|
||||
@@ -336,6 +346,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
pageActions: {
|
||||
externalAI: true,
|
||||
markdown: true,
|
||||
mcp: true,
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
|
||||
@@ -7,7 +7,8 @@ export default {
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
incrementalCache: () =>
|
||||
import('./openNext/incrementalCache/server').then((m) => m.default),
|
||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||
},
|
||||
},
|
||||
@@ -18,7 +19,8 @@ export default {
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
incrementalCache: () =>
|
||||
import('./openNext/incrementalCache/middleware').then((m) => m.default),
|
||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"main": "default.js",
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
"main": "do.js",
|
||||
"name": "gitbook-open-v2-do",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"vars": {
|
||||
|
||||
@@ -26,8 +26,10 @@ export default class extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||
const startTime = Date.now();
|
||||
const middlewareRequest = new Request(request.url, request);
|
||||
middlewareRequest.headers.set('x-open-next-continent', request.cf?.continent || '');
|
||||
// - `Request`s are handled by the Next server
|
||||
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||
const reqOrResp = await middlewareHandler(middlewareRequest, this.env, this.ctx);
|
||||
if (reqOrResp instanceof Response) {
|
||||
const duration = Date.now() - startTime;
|
||||
const logMessage = formatLog(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"main": "middleware.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
@@ -11,9 +12,6 @@
|
||||
"directory": "../../.open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
|
||||
+10
-89
@@ -18,15 +18,12 @@ export type KeyOptions = {
|
||||
|
||||
/**
|
||||
*
|
||||
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
|
||||
* the cache operations. It also integrates both R2 and Cache API in a single class.
|
||||
* Having our own, will allow us to customize it in the future if needed.
|
||||
* 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.
|
||||
*/
|
||||
class GitbookIncrementalCache implements IncrementalCache {
|
||||
export class GitbookIncrementalCache implements IncrementalCache {
|
||||
name = 'GitbookIncrementalCache';
|
||||
|
||||
protected localCache: Cache | undefined;
|
||||
|
||||
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||
key: string,
|
||||
cacheType?: CacheType
|
||||
@@ -34,7 +31,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
if (process.env.SHOULD_BYPASS_CACHE === 'true') {
|
||||
// We are in a local middleware environment, we should bypass the cache
|
||||
@@ -42,27 +38,17 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// Check local cache first if available
|
||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||
if (localCacheEntry) {
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404({
|
||||
...result,
|
||||
// Because we use tag cache and also invalidate them every time,
|
||||
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
|
||||
// this should improve performance even further, and reduce costs
|
||||
shouldBypassTagCache: true,
|
||||
});
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
if (!r2Object) return null;
|
||||
|
||||
const json = (await r2Object.json()) as CacheValue<CacheType>;
|
||||
const lastModified = r2Object.uploaded.getTime();
|
||||
|
||||
if (!json) return null;
|
||||
|
||||
return this.returnNullOn404({
|
||||
value: await r2Object.json(),
|
||||
lastModified: r2Object.uploaded.getTime(),
|
||||
value: json,
|
||||
lastModified,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to get from cache', e);
|
||||
@@ -89,39 +75,8 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
): Promise<void> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
|
||||
const localCache = await this.getCacheInstance();
|
||||
|
||||
try {
|
||||
await this.writeToR2(cacheKey, JSON.stringify(value));
|
||||
|
||||
//TODO: Check if there is any places where we don't have tags
|
||||
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||
];
|
||||
|
||||
// We consider R2 as the source of truth, so we update the local cache
|
||||
// only after a successful R2 write
|
||||
await localCache.put(
|
||||
this.getCacheUrlKey(cacheKey),
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value,
|
||||
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||
// In theory we should always get the `revalidate` value
|
||||
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||
'cache-tag': tags.join(','),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to set to cache', e);
|
||||
}
|
||||
@@ -131,14 +86,10 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const cacheKey = this.getR2Key(key);
|
||||
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
|
||||
try {
|
||||
await r2.delete(cacheKey);
|
||||
|
||||
// Here again R2 is the source of truth, so we delete from local cache first
|
||||
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||
} catch (e) {
|
||||
console.error('Failed to delete from cache', e);
|
||||
}
|
||||
@@ -168,12 +119,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheInstance(): Promise<Cache> {
|
||||
if (this.localCache) return this.localCache;
|
||||
this.localCache = await caches.open('incremental-cache');
|
||||
return this.localCache;
|
||||
}
|
||||
|
||||
// Utility function to generate keys for R2/Cache API
|
||||
getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string {
|
||||
let key = initialKey;
|
||||
@@ -189,28 +134,4 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
'/'
|
||||
);
|
||||
}
|
||||
|
||||
getCacheUrlKey(cacheKey: string): string {
|
||||
return `http://cache.local/${cacheKey}`;
|
||||
}
|
||||
|
||||
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
|
||||
entry: CacheValue<CacheType>
|
||||
): string[] | undefined {
|
||||
if ('tags' in entry && entry.tags) {
|
||||
return entry.tags;
|
||||
}
|
||||
|
||||
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
|
||||
const rawTags = entry.meta.headers['x-next-cache-tags'];
|
||||
if (typeof rawTags === 'string') {
|
||||
return rawTags.split(',');
|
||||
}
|
||||
}
|
||||
if ('value' in entry) {
|
||||
return entry.tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GitbookIncrementalCache();
|
||||
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.17.2",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/embed": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "1.17.5",
|
||||
"@opennextjs/aws": "^3.8.5",
|
||||
"@opennextjs/cloudflare": "^1.11.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
@@ -26,19 +31,17 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"classnames": "catalog:",
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
"feed": "^5.1.0",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
@@ -46,14 +49,17 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -69,13 +75,15 @@
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
@@ -84,20 +92,21 @@
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/psi": "^4.1.6",
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/rison": "^0.0.9",
|
||||
"bun-types": "catalog:",
|
||||
"deepmerge": "^4.3.1",
|
||||
"env-cmd": "^10.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vercel": "^39.3.0"
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^39.3.0",
|
||||
"wrangler": "^4.43.0",
|
||||
"rss-parser": "^3.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "./scripts/generate.sh",
|
||||
@@ -113,9 +122,14 @@
|
||||
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
|
||||
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
|
||||
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
|
||||
"e2e-browserless": "bun test ./tests/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
||||
]
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@ export default async function SiteDynamicLayout({
|
||||
const withTracking = shouldTrackEvents(await headers());
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
|
||||
<CustomizationRootLayout
|
||||
className="site-background"
|
||||
forcedTheme={forcedTheme}
|
||||
context={context}
|
||||
>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
+2
-11
@@ -1,16 +1,7 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||
|
||||
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 getEmbeddableDynamicContext(params);
|
||||
|
||||
return <EmbeddableAssistantPage context={context} />;
|
||||
export default async function Page() {
|
||||
return <EmbeddableAssistantPage />;
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout context={context}>
|
||||
<CustomizationRootLayout className="site-background" context={context}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
|
||||
+2
-11
@@ -1,16 +1,7 @@
|
||||
import type { RouteLayoutParams } from '@/app/utils';
|
||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||
|
||||
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);
|
||||
|
||||
return <EmbeddableAssistantPage context={context} />;
|
||||
export default async function Page() {
|
||||
return <EmbeddableAssistantPage />;
|
||||
}
|
||||
|
||||
+100
-2
@@ -16,9 +16,107 @@ export async function GET(
|
||||
return new Response(
|
||||
`
|
||||
<html>
|
||||
<head></head>
|
||||
<body></body>
|
||||
<head>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
</head>
|
||||
<body>
|
||||
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.1" x="0.566895" y="208" width="529.567" height="228" rx="16" fill="#A4A7B0"/>
|
||||
<g opacity="0.2">
|
||||
<line x1="141.567" y1="472" x2="4.56689" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="416.067" y1="472" x2="165.567" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="472" x2="440.067" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="194.567" y1="496" x2="4.56689" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="324.067" y1="496" x2="218.567" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="473.567" y1="496" x2="348.067" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="231.901" y1="520" x2="4.56689" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="291.186" y1="520" x2="255.901" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="442.058" y1="520" x2="315.186" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="520" x2="466.058" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="141.567" y1="544" x2="4.56689" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="348.191" y1="544" x2="165.567" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="184.017" y1="592" x2="4.56689" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="312.848" y1="592" x2="208.017" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="386.574" y1="592" x2="336.848" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="448.401" y1="592" x2="410.574" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="126.633" y1="616" x2="4.56689" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="401.133" y1="616" x2="150.633" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="616" x2="425.133" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="179.457" y1="640" x2="4.56689" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="329.569" y1="640" x2="203.457" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="141.567" y1="4" x2="4.56689" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="416.067" y1="4" x2="165.567" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="4" x2="440.067" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="194.567" y1="28" x2="4.56689" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="324.067" y1="28" x2="218.567" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="473.567" y1="28" x2="348.067" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="231.901" y1="52" x2="4.56689" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="291.186" y1="52" x2="255.901" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="442.058" y1="52" x2="315.186" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="52" x2="466.058" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="141.567" y1="76" x2="4.56689" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="348.191" y1="76" x2="165.567" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="184.017" y1="124" x2="4.56689" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="312.848" y1="124" x2="208.017" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="386.574" y1="124" x2="336.848" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="448.401" y1="124" x2="410.574" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="126.633" y1="148" x2="4.56689" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="401.133" y1="148" x2="150.633" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="148" x2="425.133" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="179.457" y1="172" x2="4.56689" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="329.569" y1="172" x2="203.457" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<rect x="563" y="208" width="156" height="256" rx="16" fill="#A4A7B0" opacity="0.05"/>
|
||||
<g opacity="0.2">
|
||||
<line x1="682.214" y1="228" x2="583" y2="228" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="638.472" y1="252" x2="583" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="681.396" y1="252" x2="654.472" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="617.989" y1="276" x2="583" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="699" y1="276" x2="633.989" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="603.168" y1="300" x2="583" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="657.873" y1="300" x2="619.168" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="637" y1="324" x2="583" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="688.836" y1="324" x2="653" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="657.474" y1="372" x2="583" y2="372" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="633" y1="396" x2="583" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="699" y1="396" x2="649" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="585.416" y1="420" x2="583" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="621.731" y1="420" x2="601.416" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="699" y1="420" x2="637.731" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="619.471" y1="444" x2="583" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
</body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
<script>
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
});
|
||||
window.GitBook('open');
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
|
||||
import { throwIfDataError } from '@/lib/data';
|
||||
import { joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { findSiteSpaceBy } from '@/lib/sites';
|
||||
import { createMcpHandler } from 'mcp-handler';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
async function handler(
|
||||
nextRequest: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const { dataFetcher, linker, site } = context;
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.tool(
|
||||
'searchDocumentation',
|
||||
`Search across the documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about ${site.title}, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages.`,
|
||||
{
|
||||
query: z.string(),
|
||||
},
|
||||
async ({ query }) => {
|
||||
const results = await throwIfDataError(
|
||||
dataFetcher.searchSiteContent({
|
||||
organizationId: context.organizationId,
|
||||
siteId: site.id,
|
||||
query,
|
||||
scope: { mode: 'all' },
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: results.flatMap((spaceResult) => {
|
||||
const found = findSiteSpaceBy(
|
||||
context.structure,
|
||||
(siteSpace) => siteSpace.space.id === spaceResult.id
|
||||
);
|
||||
const spaceURL = found?.siteSpace.urls.published;
|
||||
if (!spaceURL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaceResult.pages.map((pageResult) => {
|
||||
const pageURL = linker.toAbsoluteURL(
|
||||
linker.toLinkForContent(
|
||||
joinPathWithBaseURL(spaceURL, pageResult.path)
|
||||
)
|
||||
);
|
||||
|
||||
const body = pageResult.sections
|
||||
?.map((section) => section.body)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text: [
|
||||
`Title: ${pageResult.title}`,
|
||||
`Link: ${pageURL}`,
|
||||
body ? `Content: ${body}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
{},
|
||||
{
|
||||
basePath: context.linker.toPathInSite('~gitbook/'),
|
||||
streamableHttpEndpoint: '/mcp',
|
||||
maxDuration: 60,
|
||||
verboseLogs: true,
|
||||
disableSse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Next.js request.url is the original URL and not the rewritten one from the middleware
|
||||
const requestURL = new URL(
|
||||
context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/mcp'))
|
||||
);
|
||||
requestURL.search = nextRequest.nextUrl.search;
|
||||
|
||||
const request = new Request(requestURL, nextRequest);
|
||||
return mcpHandler(request);
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
}
|
||||
@@ -16,14 +16,14 @@ export function AIMessageView(
|
||||
) {
|
||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||
|
||||
return (
|
||||
return message.steps.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{message.steps.map((step, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={tcls(
|
||||
'flex animate-fade-in-slow flex-col gap-2',
|
||||
'flex flex-col gap-2',
|
||||
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||
)}
|
||||
>
|
||||
@@ -35,7 +35,7 @@ export function AIMessageView(
|
||||
wrapBlocksInSuspense: false,
|
||||
withLinkPreviews,
|
||||
}}
|
||||
style="mt-2 space-y-4 empty:hidden"
|
||||
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
|
||||
/>
|
||||
|
||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||
@@ -45,5 +45,5 @@ export function AIMessageView(
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
|
||||
const { toolCall, context } = props;
|
||||
|
||||
return (
|
||||
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
|
||||
<div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
|
||||
<Icon
|
||||
icon={getIconForToolCall(toolCall)}
|
||||
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;
|
||||
|
||||
return (
|
||||
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
|
||||
<details className="-ml-5 group flex w-full flex-col">
|
||||
<summary
|
||||
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',
|
||||
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
|
||||
) : null}
|
||||
</summary>
|
||||
{hasResults ? (
|
||||
<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">
|
||||
<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">
|
||||
<ol className="space-y-1">
|
||||
{searchResultsWithHrefs.map((result, index) => (
|
||||
<li
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSearch } from '../Search/useSearch';
|
||||
|
||||
@@ -104,16 +104,7 @@ export function useAI(): AIContext {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
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>
|
||||
),
|
||||
icon: <AISearchIcon />,
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
@@ -137,7 +128,7 @@ export function useAI(): AIContext {
|
||||
setSearchState((prev) => ({
|
||||
ask: null, // Reset ask as we assume the assistant will handle it
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
assistant.open(query);
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -159,7 +159,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -374,7 +374,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
@@ -435,7 +435,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatState,
|
||||
useAI,
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
} from '../AI';
|
||||
@@ -23,15 +25,15 @@ import {
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { useNow } from '../hooks';
|
||||
import { Button } from '../primitives';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { AIChatControlButton } from './AIChatControlButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
|
||||
export function AIChat(props: { trademark: boolean }) {
|
||||
const { trademark } = props;
|
||||
|
||||
export function AIChat() {
|
||||
const { config } = useAI();
|
||||
const language = useLanguage();
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
@@ -65,21 +67,22 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
}
|
||||
}, [chat.opened, trackEvent]);
|
||||
|
||||
if (!chat.opened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="ai-chat"
|
||||
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"
|
||||
className={tcls(
|
||||
'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 circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||
<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">
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={trademark} />
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, trademark)}
|
||||
{getAIChatName(language, config.trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
@@ -96,7 +99,7 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
|
||||
<AIChatBody chatController={chatController} chat={chat} />
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrame>
|
||||
</div>
|
||||
@@ -143,10 +146,33 @@ export function AIChatSubtitle(props: {
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
|
||||
{chat.messages[chat.messages.length - 1]?.content
|
||||
? tString(language, 'ai_chat_working')
|
||||
: tString(language, 'ai_chat_thinking')}
|
||||
<EmbeddableFrameSubtitle
|
||||
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
|
||||
>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
? 'animate-blur-in-slow'
|
||||
: 'hidden'
|
||||
: 'animate-blur-out-slow'
|
||||
)}
|
||||
>
|
||||
{t(language, 'ai_chat_working')}
|
||||
</span>
|
||||
<span
|
||||
className={tcls(
|
||||
'absolute left-0',
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
? 'animate-blur-out-slow'
|
||||
: 'animate-blur-in-slow'
|
||||
: 'hidden'
|
||||
)}
|
||||
>
|
||||
{t(language, 'ai_chat_thinking')}
|
||||
</span>
|
||||
</EmbeddableFrameSubtitle>
|
||||
);
|
||||
}
|
||||
@@ -157,20 +183,13 @@ export function AIChatSubtitle(props: {
|
||||
export function AIChatBody(props: {
|
||||
chatController: AIChatController;
|
||||
chat: AIChatState;
|
||||
trademark: boolean;
|
||||
welcomeMessage?: string;
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const { chatController, chat, trademark, suggestions } = props;
|
||||
const { chatController, chat, 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 now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
||||
|
||||
@@ -184,67 +203,43 @@ export function AIChatBody(props: {
|
||||
return tString(language, 'ai_chat_assistant_greeting_evening');
|
||||
}, [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 (
|
||||
<>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
|
||||
style={{
|
||||
paddingBottom: `${inputHeight}px`,
|
||||
}}
|
||||
<ScrollContainer
|
||||
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="p-4 gutter-stable flex flex-col gap-4"
|
||||
orientation="vertical"
|
||||
fadeEdges={['leading']}
|
||||
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
||||
<div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
|
||||
<AIChatIcon
|
||||
state="intro"
|
||||
trademark={trademark}
|
||||
className="size-16 animate-[present_500ms_200ms_both]"
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-[fadeIn_500ms_400ms_both]">
|
||||
<h5 className=" text-center font-bold text-lg text-tint-strong">
|
||||
{timeGreeting}
|
||||
</h5>
|
||||
<p className="text-center text-tint">
|
||||
{t(language, 'ai_chat_assistant_description')}
|
||||
</p>
|
||||
<div className="flex grow flex-col">
|
||||
<div className="my-auto flex flex-row items-center gap-4 pb-6 [@container(min-height:400px)]:flex-col">
|
||||
<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
|
||||
state="intro"
|
||||
trademark={trademark}
|
||||
className="size-8 text-primary [@container(min-height:400px)]:size-16"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start [@container(min-height:400px)]:items-center">
|
||||
<h5
|
||||
className="animate-blur-in-slow font-bold text-lg text-tint-strong [@container(min-height:400px)]:text-center"
|
||||
style={{ animationDelay: '.5s' }}
|
||||
data-testid="ai-chat-time-greeting"
|
||||
>
|
||||
{timeGreeting}
|
||||
</h5>
|
||||
<p
|
||||
className="animate-blur-in-slow text-tint [@container(min-height:400px)]:text-center"
|
||||
style={{ animationDelay: '.6s' }}
|
||||
>
|
||||
{t(language, 'ai_chat_assistant_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!chat.error ? (
|
||||
<AIChatSuggestedQuestions
|
||||
@@ -254,17 +249,11 @@ export function AIChatBody(props: {
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<AIChatMessages
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
lastUserMessageRef={lastUserMessageRef}
|
||||
/>
|
||||
<AIChatMessages chat={chat} chatController={chatController} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
</ScrollContainer>
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{/* Display an error banner when something went wrong. */}
|
||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { Assistant } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
@@ -24,7 +23,7 @@ export function AIChatButton(props: {
|
||||
iconOnly={!showLabel}
|
||||
size="medium"
|
||||
variant="header"
|
||||
className={tcls('h-9 px-2.5')}
|
||||
className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden"
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
@@ -38,7 +37,7 @@ export function AIChatButton(props: {
|
||||
}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, IconStyle } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
@@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatIcon = ({
|
||||
export function AIChatIcon({
|
||||
className = 'size-4',
|
||||
size,
|
||||
trademark = true,
|
||||
state = 'default',
|
||||
...props
|
||||
}: AIChatIconProps) => {
|
||||
}: AIChatIconProps) {
|
||||
if (!trademark) {
|
||||
return (
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
{...props}
|
||||
className={tcls(
|
||||
className,
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
@@ -163,4 +164,36 @@ export const AIChatIcon = ({
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function AISearchIcon({
|
||||
className = 'size-4',
|
||||
state = 'default',
|
||||
}: Pick<AIChatIconProps, 'className' | 'state'>) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
state === 'intro' && 'animate-[fadeIn_1s_both,orbit_1s_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[fadeIn_1s_both,orbit_1s_ease-out,orbit_2s_1s_infinite_forwards_linear]'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<Icon icon="search" className={className} />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'absolute top-[15.7%] left-[15.6%] size-[50%]',
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: '',
|
||||
state === 'intro'
|
||||
? 'animate-[spin_2s_.5s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAIChatState } from '../AI/useAIChat';
|
||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
|
||||
@@ -19,6 +20,7 @@ export function AIChatInput(props: {
|
||||
const { value, onChange, onSubmit, disabled, loading } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const chat = useAIChatState();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -32,7 +34,7 @@ export function AIChatInput(props: {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled && !loading) {
|
||||
if (chat.opened && !disabled && !loading) {
|
||||
// Add a small delay to ensure the input is rendered before focusing
|
||||
// This fixes inconsistent focus behaviour across browsers
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -41,7 +43,7 @@ export function AIChatInput(props: {
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [disabled, loading]);
|
||||
}, [disabled, loading, chat.opened]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+i',
|
||||
@@ -55,7 +57,7 @@ export function AIChatInput(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex animate-blur-in-slow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl 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">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
disabled={disabled || loading}
|
||||
@@ -106,7 +108,7 @@ export function AIChatInput(props: {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
||||
<HoverCardRoot>
|
||||
<HoverCardRoot openDelay={500}>
|
||||
<HoverCard
|
||||
className="max-w-xs bg-tint p-2 text-sm text-tint"
|
||||
arrow={{ className: 'fill-tint-3' }}
|
||||
@@ -140,7 +142,7 @@ export function AIChatInput(props: {
|
||||
<span className="leading-none">
|
||||
{t(language, 'ai_chat_context_title')}
|
||||
</span>
|
||||
<Icon icon="question-circle" className="size-3" />
|
||||
<Icon icon="question-circle" className="size-3 shrink-0" />
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
</HoverCardRoot>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import type React from 'react';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
@@ -11,87 +10,133 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
export function AIChatMessages(props: {
|
||||
chat: AIChatState;
|
||||
chatController: AIChatController;
|
||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { chat, chatController, lastUserMessageRef } = props;
|
||||
const { chat, chatController } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{chat.messages.map((message, index) => {
|
||||
const isLastMessage = index === chat.messages.length - 1;
|
||||
const isLastUserMessage =
|
||||
message.role === AIMessageRole.User &&
|
||||
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
||||
// Group messages: user messages start a new group, all following messages until next user message belong to that group
|
||||
type Message = (typeof chat.messages)[0];
|
||||
type MessageGroup = { message: Message; originalIndex: number };
|
||||
const messageGroups: Array<Array<MessageGroup>> = [];
|
||||
let currentGroup: Array<MessageGroup> = [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||
data-testid="ai-chat-message"
|
||||
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',
|
||||
'break-words',
|
||||
'group/message',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'text-tint-strong'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{message.content ? message.content : null}
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
{isLastMessage && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||
{!message.content ? <HoldMessage /> : null}
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
// Add the last group if it exists
|
||||
if (currentGroup.length > 0) {
|
||||
messageGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
return messageGroups.map((group, groupIndex) => {
|
||||
const isLastGroup = group === messageGroups[messageGroups.length - 1];
|
||||
return (
|
||||
<div
|
||||
key={groupIndex}
|
||||
id={`message-group-${groupIndex}`}
|
||||
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(
|
||||
'flex flex-col gap-6',
|
||||
'break-words',
|
||||
'group/message',
|
||||
'animate-blur-in-slow',
|
||||
isLastMessage ? 'basis-full' : '',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'origin-top-left text-tint-strong'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
|
||||
{isLastMessage && message.role === AIMessageRole.Assistant ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
|
||||
chat.loading ? '' : 'hidden opacity-0'
|
||||
)}
|
||||
>
|
||||
<HoldMessage className={message.content ? 'hidden' : ''} />
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function HoldMessage() {
|
||||
export function HoldMessage({
|
||||
breakLines = false,
|
||||
className,
|
||||
}: { breakLines?: boolean; className?: string }) {
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
|
||||
<div
|
||||
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')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="animate-fade-in-slow"
|
||||
className="animate-blur-in-slow"
|
||||
style={{
|
||||
animationDelay: `${5000 + index * 200}ms`,
|
||||
}}
|
||||
@@ -99,12 +144,13 @@ function HoldMessage() {
|
||||
{word}{' '}
|
||||
</span>
|
||||
))}
|
||||
{breakLines ? <br /> : null}
|
||||
{tString(language, 'ai_chat_hold_message_2')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="animate-fade-in-slow"
|
||||
className="animate-blur-in-slow"
|
||||
style={{
|
||||
animationDelay: `${10000 + index * 200}ms`,
|
||||
}}
|
||||
@@ -122,9 +168,9 @@ function LoadingSkeleton() {
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
||||
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"
|
||||
style={{
|
||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
||||
width: `calc(${(4 - (index % 4)) * 8 + 14}% - 4px)`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -17,15 +17,15 @@ export default function AIChatSuggestedQuestions(props: {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-start gap-2 self-start">
|
||||
{suggestions.map((question, index) => (
|
||||
<Button
|
||||
key={question}
|
||||
variant="secondary"
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
||||
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 px-3 py-1.5 hover:bg-primary-hover"
|
||||
style={{
|
||||
animationDelay: `${800 + index * 100}ms`,
|
||||
animationDelay: `${1000 + index * 100}ms`,
|
||||
}}
|
||||
onClick={() => chatController.postMessage({ message: question })}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function AIChatToolConfirmations(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
|
||||
{chat.pendingTools.map((tool, index) => (
|
||||
<div
|
||||
className="flex w-full animate-present-slow flex-col gap-1"
|
||||
|
||||
@@ -24,7 +24,9 @@ export function AIResponseFeedback(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={tcls('flex h-fit items-center', className)}>
|
||||
<div
|
||||
className={tcls('flex h-fit animate-[heightIn_.5s_ease_both] items-center', className)}
|
||||
>
|
||||
<Button
|
||||
icon="thumbs-up"
|
||||
iconOnly
|
||||
@@ -35,7 +37,7 @@ export function AIResponseFeedback(props: {
|
||||
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
|
||||
)}
|
||||
size="medium"
|
||||
style={{ animationDuration: '.5s' }}
|
||||
style={{ animationDelay: '.5s', animationDuration: '.5s' }}
|
||||
onClick={() => handleRating(1)}
|
||||
disabled={rating !== null}
|
||||
active={rating === 1}
|
||||
@@ -51,7 +53,7 @@ export function AIResponseFeedback(props: {
|
||||
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
|
||||
)}
|
||||
size="medium"
|
||||
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
|
||||
style={{ animationDelay: '.7s', animationDuration: '.5s' }}
|
||||
onClick={() => handleRating(-1)}
|
||||
disabled={rating !== null}
|
||||
active={rating === -1}
|
||||
@@ -59,8 +61,8 @@ export function AIResponseFeedback(props: {
|
||||
/>
|
||||
{rating !== null ? (
|
||||
<span
|
||||
className="ml-2 animate-fade-in-slow text-tint-subtle"
|
||||
style={{ animationDelay: '.3s' }}
|
||||
className="ml-2 animate-blur-in-slow text-tint-subtle"
|
||||
style={{ animationDelay: '.5s' }}
|
||||
>
|
||||
{t(language, 'was_this_helpful_thank_you')}
|
||||
</span>
|
||||
|
||||
@@ -15,22 +15,28 @@ export function AIChatFollowupSuggestions(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-auto flex w-full flex-wrap justify-end gap-2">
|
||||
{chat.followUpSuggestions.map((suggestion, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
chatController.postMessage({ message: suggestion });
|
||||
}}
|
||||
label={suggestion}
|
||||
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
|
||||
size="medium"
|
||||
variant="blank"
|
||||
style={{
|
||||
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex grow flex-col">
|
||||
<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) => (
|
||||
<Button
|
||||
data-testid="ai-chat-followup-suggestion"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
chatController.postMessage({ message: 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"
|
||||
size="medium"
|
||||
variant="blank"
|
||||
style={{
|
||||
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,19 +4,14 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
|
||||
import * as React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AdaptiveVisitorClaimsData = {
|
||||
visitor: {
|
||||
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
import type { AdaptiveVisitorClaims } from './types';
|
||||
|
||||
/**
|
||||
* In-memory cache of visitor claim readers keyed by contextId.
|
||||
*/
|
||||
const adaptiveVisitorReaderCache = new Map<
|
||||
string,
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
|
||||
>();
|
||||
|
||||
function createResourceReader<T>(promise: Promise<T>) {
|
||||
@@ -52,7 +47,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return await res.json<AdaptiveVisitorClaimsData>();
|
||||
return await res.json<AdaptiveVisitorClaims>();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +59,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
return reader;
|
||||
}
|
||||
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
|
||||
|
||||
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './AdaptiveVisitorContextProvider';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
|
||||
unsigned: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AdaptiveVisitorClaims = {
|
||||
visitor: {
|
||||
claims: AdaptiveVisitorClaimsData;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Variables } from '@gitbook/api';
|
||||
import type { AdaptiveVisitorClaims } from './types';
|
||||
|
||||
/**
|
||||
* Return an evaluation context to evaluate expressions.
|
||||
*/
|
||||
export function createExpressionEvaluationContext(args: {
|
||||
visitorClaims: AdaptiveVisitorClaims | null;
|
||||
variables: {
|
||||
space?: Variables;
|
||||
page?: Variables;
|
||||
};
|
||||
}) {
|
||||
const { visitorClaims, variables } = args;
|
||||
return {
|
||||
...(visitorClaims ? visitorClaims : {}),
|
||||
space: {
|
||||
vars: variables.space ?? {},
|
||||
},
|
||||
...(variables.page
|
||||
? {
|
||||
page: {
|
||||
vars: variables.page ?? {},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,143 +1,63 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { AdminToolbarClient } from './AdminToolbarClient';
|
||||
import type { AdminToolbarContext } from './types';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
export interface AdminToolbarProps {
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
|
||||
function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'fixed',
|
||||
'bottom-5',
|
||||
'left-1/2',
|
||||
'z-50',
|
||||
'transform',
|
||||
'-translate-x-1/2',
|
||||
'rounded-full',
|
||||
|
||||
'bg-tint-12/9',
|
||||
'dark:bg-tint-1/9',
|
||||
|
||||
'shadow-lg',
|
||||
'min-h-10',
|
||||
'min-w-40',
|
||||
'p-2',
|
||||
'max-w-md',
|
||||
'border-tint-12/1',
|
||||
'backdrop-blur-md'
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={null}>{props.children}</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||
* Server component that determines what type of toolbar to show and passes data to client component
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { context } = props;
|
||||
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
// Create a minimal context to avoid serializing and passing too many data to the client
|
||||
const minimalContext: AdminToolbarContext = {
|
||||
organizationId: context.organizationId,
|
||||
revisionId: context.revisionId,
|
||||
space: {
|
||||
id: context.space.id,
|
||||
revision: context.space.revision,
|
||||
urls: {
|
||||
app: context.space.urls.app,
|
||||
},
|
||||
},
|
||||
changeRequest: context.changeRequest
|
||||
? {
|
||||
id: context.changeRequest.id,
|
||||
number: context.changeRequest.number,
|
||||
subject: context.changeRequest.subject,
|
||||
revision: context.changeRequest.revision,
|
||||
updatedAt: context.changeRequest.updatedAt,
|
||||
createdBy: {
|
||||
displayName: context.changeRequest.createdBy.displayName,
|
||||
},
|
||||
urls: {
|
||||
app: context.changeRequest.urls.app,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
revision: {
|
||||
createdAt: context.revision.createdAt,
|
||||
urls: {
|
||||
app: context.revision.urls.app,
|
||||
},
|
||||
git: context.revision.git
|
||||
? {
|
||||
url: context.revision.git.url,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
site: {
|
||||
id: context.site.id,
|
||||
title: context.site.title,
|
||||
urls: {
|
||||
app: context.site.urls.app,
|
||||
published: context.site.urls.published,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<RevisionToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { space, changeRequest } = context;
|
||||
|
||||
if (!changeRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarLayout>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="code-branch" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
||||
</p>
|
||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
||||
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
||||
</p>
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<RefreshChangeRequestButton
|
||||
spaceId={space.id}
|
||||
changeRequestId={changeRequest.id}
|
||||
revisionId={changeRequest.revision}
|
||||
updatedAt={new Date(changeRequest.updatedAt).getTime()}
|
||||
/>
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
</ToolbarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { revision } = context;
|
||||
|
||||
return (
|
||||
<ToolbarLayout>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="code-commit" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
Revision created <DateRelative value={revision.createdAt} />
|
||||
</p>
|
||||
{revision.git ? (
|
||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
||||
{revision.git.message}
|
||||
</p>
|
||||
) : null}
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
{revision.git?.url ? (
|
||||
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
||||
<Icon
|
||||
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
||||
className="size-4"
|
||||
/>
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
</ToolbarLayout>
|
||||
);
|
||||
return <AdminToolbarClient context={minimalContext} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { MotionConfig } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarBody,
|
||||
ToolbarButton,
|
||||
ToolbarButtonGroup,
|
||||
type ToolbarButtonProps,
|
||||
ToolbarSeparator,
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
|
||||
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
|
||||
const {
|
||||
minified,
|
||||
setMinified,
|
||||
shouldAutoExpand,
|
||||
hidden,
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
} = useToolbarVisibility({
|
||||
onPersistentClose,
|
||||
onSessionClose,
|
||||
onToggleMinify,
|
||||
});
|
||||
|
||||
const visitorSession = useVisitorSession();
|
||||
|
||||
const toolbarControls: ToolbarControlsContextValue = {
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
shouldAutoExpand,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there is a change request, show the change request toolbar
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<ChangeRequestToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<RevisionToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitorSession?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<AuthenticatedUserToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
|
||||
*/
|
||||
export function ToolbarControlsWrapper(
|
||||
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
|
||||
) {
|
||||
const { children, value } = props;
|
||||
return (
|
||||
<ToolbarControlsProvider value={value}>
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">{children}</MotionConfig>
|
||||
</IframeWrapper>
|
||||
</ToolbarControlsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarViewProps {
|
||||
context: AdminToolbarContext;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { changeRequest, site } = context;
|
||||
if (!changeRequest) {
|
||||
throw new Error('Change request is not set');
|
||||
}
|
||||
|
||||
const author = changeRequest.createdBy.displayName;
|
||||
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: changeRequest.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
suffix={`${changeRequest.subject || 'Untitled'}`}
|
||||
/>
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Comment in app */}
|
||||
<ToolbarButton
|
||||
title="Comment in a GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${changeRequest.urls.app}~/comments`,
|
||||
siteId: site.id,
|
||||
buttonId: 'comment',
|
||||
})}
|
||||
icon="comment"
|
||||
/>
|
||||
|
||||
{/* Open published/live site */}
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Open CR in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View change request in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: changeRequest.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'change-request',
|
||||
})}
|
||||
icon="code-pull-request"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, site } = context;
|
||||
if (!revision) {
|
||||
throw new Error('Revision is not set');
|
||||
}
|
||||
|
||||
const gitURL = revision.git?.url;
|
||||
const isGitHub = gitURL?.includes('github.com');
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Created <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarActions>
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
title={
|
||||
gitURL ? (
|
||||
`Open commit in ${gitProvider}`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
Setup GitSync to edit using Git{' '}
|
||||
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
|
||||
<a
|
||||
href="https://gitbook.com/docs/getting-started/git-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
href={gitURL}
|
||||
disabled={!gitURL}
|
||||
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
|
||||
/>
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
title="View this revision in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: revision.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'revision',
|
||||
})}
|
||||
icon="code-commit"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, space, site } = context;
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
minified={minified}
|
||||
onMinifiedChange={onMinifiedChange}
|
||||
label="Only visible to your GitBook organization"
|
||||
>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Updated <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gears"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Customize in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/customization/general`,
|
||||
siteId: site.id,
|
||||
buttonId: 'customize',
|
||||
})}
|
||||
icon="palette"
|
||||
/>
|
||||
|
||||
{/* Open insights in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open insights in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/insights`,
|
||||
siteId: site.id,
|
||||
buttonId: 'insights',
|
||||
})}
|
||||
icon="chart-simple"
|
||||
/>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarActions(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<ToolbarButtonGroup>
|
||||
{children}
|
||||
<HideToolbarButton />
|
||||
</ToolbarButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageButton(props: {
|
||||
href: string;
|
||||
siteId: string;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { href, motionValues, siteId } = props;
|
||||
const pagePath = useCurrentPagePath();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pencil"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append utm parameters to a URL to track usage of the toolbar.
|
||||
*/
|
||||
function getToolbarHref({
|
||||
href,
|
||||
siteId,
|
||||
buttonId,
|
||||
}: { href: string; siteId: string; buttonId: string }) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('utm_source', 'content');
|
||||
url.searchParams.set('utm_medium', 'toolbar');
|
||||
url.searchParams.set('utm_campaign', siteId);
|
||||
url.searchParams.set('utm_content', buttonId);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
.svgLogo {
|
||||
--logo-fill: var(--color-neutral-100);
|
||||
--seg-A-color: #2782c4;
|
||||
--seg-B-color: #43b7f2;
|
||||
--seg-C-color: #8be2ff;
|
||||
--trace-color: #46474c;
|
||||
|
||||
|
||||
--T: 2s;
|
||||
shape-rendering: geometricPrecision;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.static .trace {
|
||||
animation: none;
|
||||
fill: var(--logo-fill);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.static .seg {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Base segment animation */
|
||||
.seg {
|
||||
opacity: 0;
|
||||
animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear,
|
||||
segALen var(--T) linear;
|
||||
animation-delay: 0.06s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.segA {
|
||||
animation-name: segFade, segAMove, segALen;
|
||||
}
|
||||
|
||||
.segB {
|
||||
animation-name: segFade, segBMove, segBLen;
|
||||
}
|
||||
|
||||
.segC {
|
||||
animation-name: segFade, segCMove, segCLen;
|
||||
}
|
||||
|
||||
.trace {
|
||||
animation: traceFill var(--T) ease both 0.06s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes traceFill {
|
||||
0% {
|
||||
fill: transparent;
|
||||
stroke:var(--trace-color);
|
||||
}
|
||||
95% {
|
||||
fill: transparent;
|
||||
stroke:var(--trace-color);
|
||||
}
|
||||
100% {
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Segment A animation */
|
||||
@keyframes segAMove {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: var(--seg-A-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.5;
|
||||
stroke: var(--seg-A-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segALen {
|
||||
0% {
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
}
|
||||
95% {
|
||||
stroke-dasharray: 0.22 0.78;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment B animation */
|
||||
@keyframes segBMove {
|
||||
0% {
|
||||
stroke-dashoffset: -0.18;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.72;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1.18;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segBLen {
|
||||
0%,
|
||||
50%,
|
||||
95% {
|
||||
stroke-dasharray: 0.2 0.8;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment C animation */
|
||||
@keyframes segCMove {
|
||||
0% {
|
||||
stroke-dashoffset: -0.38;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.92;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1.38;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segCLen {
|
||||
0% {
|
||||
stroke-dasharray: 0.22 0.78;
|
||||
}
|
||||
95% {
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment fade animation - stays visible at end */
|
||||
@keyframes segFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.seg {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.trace {
|
||||
animation: none;
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type React from 'react';
|
||||
import styles from './AnimatedLogo.module.css';
|
||||
|
||||
interface AnimatedLogoProps {
|
||||
shouldAnimate?: boolean;
|
||||
}
|
||||
|
||||
export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
|
||||
const { shouldAnimate = true } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)}
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background trace */}
|
||||
<path
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
className={styles.trace}
|
||||
/>
|
||||
{/* Three individual paths to create the 'chasing' segmented stroke effect */}
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segA)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.18 0.82"
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segB)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.20 0.80"
|
||||
strokeDashoffset={-0.18}
|
||||
/>
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segC)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.22 0.78"
|
||||
strokeDashoffset={-0.38}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const dPath =
|
||||
'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z';
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { motion } from 'motion/react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useOnClickOutside } from 'usehooks-ts';
|
||||
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
import styles from './Toolbar.module.css';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
const ARC_DURATION_SECONDS = 0.4;
|
||||
const ARC_STAGGER_MS = 80;
|
||||
const BASE_ROTATION_DEG = 95;
|
||||
const ROTATION_STEP_DEG = 18;
|
||||
|
||||
interface HideToolbarButtonProps {
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide menu trigger. Expands a macOS Dock-like submenu with 3 labeled actions.
|
||||
*/
|
||||
export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
const { motionValues } = props;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const controls = useToolbarControls();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutsideArcMenu = (event: Event) => {
|
||||
// Don't close the arc if we are clicking on the button itself
|
||||
if (buttonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
// @ts-expect-error wrong type for ref
|
||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||
|
||||
// Close arc menu on scroll
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleScroll = () => setOpen(false);
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [open]);
|
||||
|
||||
const items = [
|
||||
controls?.minimize
|
||||
? {
|
||||
id: 'minimize',
|
||||
icon: 'minus',
|
||||
label: 'Minimize',
|
||||
onClick: controls.minimize,
|
||||
}
|
||||
: null,
|
||||
controls?.closeSession
|
||||
? {
|
||||
id: 'session-close',
|
||||
icon: 'xmark',
|
||||
label: 'Close for one session',
|
||||
onClick: controls.closeSession,
|
||||
}
|
||||
: null,
|
||||
controls?.closePersistent
|
||||
? {
|
||||
id: 'persistent-close',
|
||||
icon: 'ban',
|
||||
label: "Don't show again",
|
||||
onClick: controls.closePersistent,
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as Array<ArcMenuItem>;
|
||||
|
||||
const sharedMotionStyle = motionValues
|
||||
? {
|
||||
x: motionValues.x,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
ref={buttonRef}
|
||||
title={open ? 'Hide options' : 'Hide toolbar'}
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
motionValues={motionValues}
|
||||
icon="eye-slash"
|
||||
>
|
||||
{/* Expanding arc menu */}
|
||||
{open && (
|
||||
<motion.div
|
||||
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
|
||||
style={sharedMotionStyle as React.CSSProperties | undefined}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'pointer-events-none absolute left-0 overflow-visible',
|
||||
styles.arcMenuPath
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<ArcToolbarButton
|
||||
index={index}
|
||||
staggerIndex={items.length - 1 - index}
|
||||
key={item.icon}
|
||||
{...item}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onClick?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
type ArcMenuItem = {
|
||||
id: string;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
|
||||
index: number;
|
||||
staggerIndex?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
const {
|
||||
index,
|
||||
staggerIndex = index,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
onClick = () => {},
|
||||
icon,
|
||||
iconClassName,
|
||||
} = props;
|
||||
|
||||
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
|
||||
|
||||
// Calculate rotation based on position along the arc
|
||||
const calculateRotation = () => {
|
||||
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
|
||||
};
|
||||
|
||||
const itemRotation = calculateRotation();
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--target-offset-distance': targetOffset,
|
||||
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
|
||||
'--rotation-offset': `${itemRotation}deg`,
|
||||
offsetPath: 'border-box',
|
||||
offsetDistance: targetOffset,
|
||||
offsetAnchor: '0% 40%',
|
||||
offsetRotate: `auto ${itemRotation}deg`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={tcls(
|
||||
'group',
|
||||
'absolute',
|
||||
'top-0',
|
||||
'left-0',
|
||||
'w-40',
|
||||
'opacity-0',
|
||||
'pointer-events-auto',
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
styles.arcMenuItem,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex shrink-0 items-center justify-center gap-1',
|
||||
'h-8 w-8 rounded-full border',
|
||||
'truncate text-sm',
|
||||
'cursor-pointer transition-colors',
|
||||
'group-hover:-rotate-5 group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'text-tint-1 dark:text-tint-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
|
||||
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
|
||||
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
|
||||
)}
|
||||
style={{
|
||||
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
|
||||
'group-hover:rotate-2 group-hover:scale-105',
|
||||
'text-neutral-1 dark:text-neutral-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ToolbarButton } from './Toolbar';
|
||||
|
||||
// We don't show the button if the content has been updated 30s ago or less.
|
||||
const minInterval = 1000 * 30; // 5 minutes
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshChangeRequestButton(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const { updatedAt } = props;
|
||||
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const checkForUpdates = useCheckForContentUpdate(props);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setVisible(false);
|
||||
}
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Show the button if the content has been updated more than 30s ago.
|
||||
React.useEffect(() => {
|
||||
if (updatedAt < Date.now() - minInterval) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [updatedAt]);
|
||||
|
||||
// 30sec after being hidden, we show the button again
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, minInterval);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshContentButton(props: {
|
||||
refreshForUpdates: () => Promise<void>;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { refreshForUpdates, motionValues } = props;
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshForUpdates]);
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh for latest changes"
|
||||
onClick={(event) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
motionValues={motionValues}
|
||||
icon="rotate"
|
||||
iconClassName={loading ? 'animate-spin' : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.arcMenu {
|
||||
--arc-width: 505px;
|
||||
--arc-height: 400px;
|
||||
--arc-radius: 34%;
|
||||
--start-distance: -240px;
|
||||
--spread-distance: 45px;
|
||||
}
|
||||
|
||||
.arcMenuPath {
|
||||
bottom: calc(var(--arc-height) / -2);
|
||||
width: var(--arc-width);
|
||||
height: var(--arc-height);
|
||||
border-radius: var(--arc-radius);
|
||||
transform: translate(0%, 0%);
|
||||
}
|
||||
|
||||
.arcMenuItem {
|
||||
animation-name: hide-toolbar-arc-enter;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
animation-duration: var(--arc-duration, 0.4s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
offset-path: border-box;
|
||||
offset-anchor: 0% 0%;
|
||||
offset-rotate: auto var(--rotation-offset, 0deg);
|
||||
}
|
||||
|
||||
@keyframes hide-toolbar-arc-enter {
|
||||
from {
|
||||
offset-distance: var(--start-distance);
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import type * as React from 'react';
|
||||
import {
|
||||
AnimatePresence,
|
||||
type MotionValue,
|
||||
motion,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
import React, { isValidElement } from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { getCopyVariants, toolbarEasings } from './transitions';
|
||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
|
||||
export function Toolbar(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
interface ToolbarProps {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function Toolbar(props: ToolbarProps) {
|
||||
const { children, label, minified, onMinifiedChange } = props;
|
||||
const controls = useToolbarControls();
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const autoExpandTriggeredRef = React.useRef(false);
|
||||
|
||||
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
||||
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
const handleLoad = () => {
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
handleLoad();
|
||||
} else {
|
||||
window.addEventListener('load', handleLoad);
|
||||
return () => window.removeEventListener('load', handleLoad);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// After toolbar appears, wait, then show the full content
|
||||
React.useEffect(() => {
|
||||
if (!isReady || autoExpandTriggeredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldAutoExpand) {
|
||||
// When we already know the toolbar should stay expanded (e.g. the user previously
|
||||
// opened it this session) we short-circuit the auto-expand animation and immediately
|
||||
// render the expanded state without replaying the logo animation.
|
||||
autoExpandTriggeredRef.current = true;
|
||||
setShouldAnimateLogo(false);
|
||||
return;
|
||||
}
|
||||
|
||||
autoExpandTriggeredRef.current = true;
|
||||
|
||||
// On a fresh session we let the toolbar appear in its compact form, play the logo
|
||||
// animation, and only then expand the toolbar. The timeout mirrors the duration of the
|
||||
// logo animation so both transitions feel connected.
|
||||
const expandAfterTimeout = setTimeout(() => {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
|
||||
|
||||
return () => clearTimeout(expandAfterTimeout);
|
||||
}, [isReady, onMinifiedChange, shouldAutoExpand]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!minified) {
|
||||
// Any manual expansion should stop the logo animation so the icon stays in its
|
||||
// “settled” state once the toolbar is open.
|
||||
setShouldAnimateLogo(false);
|
||||
}
|
||||
}, [minified]);
|
||||
|
||||
// Don't render anything until page is ready
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-4',
|
||||
'text-sm',
|
||||
'px-4',
|
||||
'py-1',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Tooltip label={label}>
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
|
||||
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
|
||||
)}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarBody(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-col gap-1">{props.children}</div>;
|
||||
return <div className="flex flex-col items-start px-3">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-row gap-2">{props.children}</div>;
|
||||
}
|
||||
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
|
||||
const { buttonMotionValues } = useMagnificationEffect({
|
||||
childrenCount: buttonChildren.length,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'size-11',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1', // Equal to text-tint-strong in dark mode
|
||||
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
|
||||
'hover:bg-tint-12',
|
||||
'dark:hover:bg-tint-1',
|
||||
'hover:shadow-lg',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
variants={toolbarEasings.parent}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{buttonChildren.map((child, index) => {
|
||||
const childEl = child as React.ReactElement;
|
||||
const childKey = childEl.key ?? `toolbar-button-${index}`;
|
||||
return (
|
||||
<ToolbarButtonWrapper
|
||||
key={childKey}
|
||||
child={childEl}
|
||||
rawMotionValues={buttonMotionValues[index]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
|
||||
motionValues?: {
|
||||
scale: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
};
|
||||
icon: IconName;
|
||||
iconClassName?: string;
|
||||
title?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
|
||||
const {
|
||||
title,
|
||||
disabled,
|
||||
motionValues,
|
||||
className,
|
||||
style,
|
||||
href,
|
||||
onClick,
|
||||
icon,
|
||||
iconClassName,
|
||||
children,
|
||||
} = props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
||||
{children ? children : null}
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'flex',
|
||||
'relative',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'size-8',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
|
||||
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
ToolbarButton.displayName = 'ToolbarButton';
|
||||
|
||||
function ToolbarButtonWrapper(props: {
|
||||
child: React.ReactElement;
|
||||
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
|
||||
}) {
|
||||
const { child, rawMotionValues } = props;
|
||||
|
||||
// Convert the raw motion values to smooth spring easings
|
||||
const springScale = useSpring(rawMotionValues?.scale.get() ?? 1, {
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
});
|
||||
const springX = useSpring(rawMotionValues?.x.get() ?? 0, { stiffness: 400, damping: 30 });
|
||||
|
||||
// Sync springs with raw motion values
|
||||
React.useEffect(() => {
|
||||
if (!rawMotionValues) return;
|
||||
|
||||
const unsubScale = rawMotionValues.scale.on('change', (v) => springScale.set(v));
|
||||
const unsubX = rawMotionValues.x.on('change', (v) => springX.set(v));
|
||||
|
||||
return () => {
|
||||
unsubScale();
|
||||
unsubX();
|
||||
};
|
||||
}, [rawMotionValues, springScale, springX]);
|
||||
|
||||
const motionValues = {
|
||||
scale: springScale,
|
||||
x: springX,
|
||||
};
|
||||
|
||||
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.cloneElement(child, {
|
||||
motionValues,
|
||||
});
|
||||
}
|
||||
|
||||
export function ToolbarSeparator() {
|
||||
return <div className="h-5 w-px bg-tint-1/3" />;
|
||||
}
|
||||
|
||||
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs ">
|
||||
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
|
||||
<ToolbarTitleSuffix title={props.suffix} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTitlePrefix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(0)}
|
||||
className="truncate font-medium text-neutral-1 dark:text-neutral-12"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTitleSuffix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
>
|
||||
{props.subtitle}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
export interface ToolbarControlsContextValue {
|
||||
minimize: () => void;
|
||||
closeSession?: () => void;
|
||||
closePersistent?: () => void;
|
||||
shouldAutoExpand?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarControlsContext = React.createContext<ToolbarControlsContextValue | null>(null);
|
||||
|
||||
/*
|
||||
* Provides reusable state setters (mainly for hiding/showing the toolbar) for the toolbar controls propagated through to the children
|
||||
*/
|
||||
export function ToolbarControlsProvider(
|
||||
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
|
||||
) {
|
||||
const { children, value } = props;
|
||||
return (
|
||||
<ToolbarControlsContext.Provider value={value}>{children}</ToolbarControlsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToolbarControls() {
|
||||
return React.useContext(ToolbarControlsContext);
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './AdminToolbar';
|
||||
export * from './AdminToolbarClient';
|
||||
export * from './IframeWrapper';
|
||||
export * from './Toolbar';
|
||||
export * from './transitions';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Transition } from 'motion/react';
|
||||
|
||||
// Slight bounce but minimal overshoot esp. at the minimized state.
|
||||
const spring = {
|
||||
type: 'spring' as const,
|
||||
stiffness: 130,
|
||||
damping: 19,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
const parent = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delayChildren: 0.4,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const staggeringChild = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.7,
|
||||
},
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
type: 'spring',
|
||||
} as Transition,
|
||||
},
|
||||
};
|
||||
|
||||
export const minifyButtonAnimation = {
|
||||
initial: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const getCopyVariants = (position: number) => {
|
||||
return {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -10,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
},
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
delay: position / 10 + 0.3,
|
||||
} as Transition,
|
||||
};
|
||||
};
|
||||
|
||||
export const toolbarEasings = {
|
||||
spring,
|
||||
parent,
|
||||
staggeringChild,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized
|
||||
*/
|
||||
|
||||
export type MinimalChangeRequest = {
|
||||
id: string;
|
||||
number: number;
|
||||
subject: string | null;
|
||||
revision: string;
|
||||
updatedAt: string;
|
||||
createdBy: {
|
||||
displayName: string;
|
||||
};
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalRevision = {
|
||||
createdAt: string;
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
git?: {
|
||||
url: string | undefined;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MinimalSpace = {
|
||||
id: string;
|
||||
revision: string;
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalSite = {
|
||||
id: string;
|
||||
title: string;
|
||||
urls: {
|
||||
app: string;
|
||||
published: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminToolbarContext = {
|
||||
organizationId: string;
|
||||
revisionId: string;
|
||||
space: MinimalSpace;
|
||||
changeRequest: MinimalChangeRequest | null;
|
||||
revision: MinimalRevision;
|
||||
site: MinimalSite;
|
||||
};
|
||||
|
||||
export interface AdminToolbarClientProps {
|
||||
context: AdminToolbarContext;
|
||||
onSessionClose?: () => void;
|
||||
onPersistentClose?: () => void;
|
||||
onToggleMinify?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { type MotionValue, motionValue } from 'motion/react';
|
||||
import React from 'react';
|
||||
|
||||
interface MagnificationConfig {
|
||||
/** Size of each button in pixels - used for spacing calculations */
|
||||
buttonSize?: number;
|
||||
/** Maximum scale factor for buttons when mouse is directly over them */
|
||||
maxScale?: number;
|
||||
/** Distance in pixels from mouse where buttons start scaling */
|
||||
influenceRadius?: number;
|
||||
/** Multiplier for spacing between buttons - higher values create more space */
|
||||
spacingMultiplier?: number;
|
||||
/** Controls scaling curve - higher values make scaling more dramatic near mouse */
|
||||
scaleExponent?: number;
|
||||
/** Padding around container for mouse detection in pixels */
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
const defaultConfig: Required<MagnificationConfig> = {
|
||||
buttonSize: 32, // Standard button size for 32px (size-8) buttons
|
||||
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
|
||||
influenceRadius: 80, // ~2.5 button widths of influence
|
||||
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
|
||||
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
|
||||
padding: 10, // Small buffer zone around container edges
|
||||
};
|
||||
|
||||
const resetMotionValues = (
|
||||
motionValues: Array<{ scale: MotionValue<number>; x: MotionValue<number> }>
|
||||
) => {
|
||||
motionValues.forEach(({ scale, x }) => {
|
||||
scale.set(1);
|
||||
x.set(0);
|
||||
});
|
||||
};
|
||||
|
||||
const captureButtonPositions = (buttons: HTMLElement[]) => {
|
||||
// Reset transforms to get original positions
|
||||
buttons.forEach((button) => {
|
||||
button.style.transform = '';
|
||||
});
|
||||
return buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const calculateScale = (
|
||||
mouseX: number,
|
||||
buttonCenterX: number,
|
||||
containerRect: DOMRect,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
// Calculate only X-axis distance from mouse to button center
|
||||
const distance = Math.abs(mouseX - buttonCenterX);
|
||||
|
||||
if (distance > config.influenceRadius) return 1;
|
||||
|
||||
// Calculate distance from container edge (X-axis only)
|
||||
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
|
||||
const distanceFromEdge = distanceFromEdgeX;
|
||||
|
||||
// Define the "heart" zone - 8px from center (16x16px total area)
|
||||
const heartZoneRadius = 8;
|
||||
|
||||
let baseScale: number;
|
||||
|
||||
if (distance <= heartZoneRadius) {
|
||||
// Within the heart zone - always at max scale
|
||||
baseScale = config.maxScale;
|
||||
} else {
|
||||
// Outside heart zone - scale from max to 1 based on distance
|
||||
const outerRadius = config.influenceRadius - heartZoneRadius;
|
||||
const outerDistance = distance - heartZoneRadius;
|
||||
const progress = Math.min(outerDistance / outerRadius, 1);
|
||||
const exponentialProgress = (1 - progress) ** config.scaleExponent;
|
||||
baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
|
||||
}
|
||||
|
||||
// Only apply entry transition if we're very close to the edge (within 10px)
|
||||
// This prevents the entry logic from interfering with normal scaling
|
||||
if (distanceFromEdge < 10) {
|
||||
const entryProgress = distanceFromEdge / 10;
|
||||
return 1 + (baseScale - 1) * entryProgress;
|
||||
}
|
||||
|
||||
return baseScale;
|
||||
};
|
||||
|
||||
const calculateSpacing = (
|
||||
buttonIndex: number,
|
||||
buttonEffects: Array<{ scale: number; translateX: number }>,
|
||||
positions: Array<{ left: number; width: number; top: number; height: number }>,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
const currentPos = positions[buttonIndex];
|
||||
if (!currentPos) return 0;
|
||||
|
||||
const buttonCenterX = currentPos.left + currentPos.width / 2;
|
||||
let leftPush = 0;
|
||||
let rightPush = 0;
|
||||
|
||||
buttonEffects.forEach((otherEffect, otherIndex) => {
|
||||
if (otherIndex === buttonIndex) return;
|
||||
|
||||
const otherPos = positions[otherIndex];
|
||||
if (!otherPos) return;
|
||||
|
||||
const otherCenterX = otherPos.left + otherPos.width / 2;
|
||||
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
|
||||
|
||||
// Only apply influence if close enough
|
||||
if (distanceBetween < config.influenceRadius * 1.5) {
|
||||
const influenceStrength = Math.max(
|
||||
0,
|
||||
1 - distanceBetween / (config.influenceRadius * 1.5)
|
||||
);
|
||||
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
|
||||
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
|
||||
|
||||
if (otherCenterX < buttonCenterX) {
|
||||
rightPush += pushAmount;
|
||||
} else {
|
||||
leftPush += pushAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rightPush - leftPush;
|
||||
};
|
||||
|
||||
export function useMagnificationEffect(props: {
|
||||
childrenCount: number;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
config?: MagnificationConfig;
|
||||
}) {
|
||||
const { childrenCount, containerRef, config } = props;
|
||||
const originalPositionsRef = React.useRef<
|
||||
Array<{ left: number; width: number; top: number; height: number }>
|
||||
>([]);
|
||||
|
||||
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
|
||||
|
||||
// Create basic motion values that will be consumed by springs in the components
|
||||
const buttonMotionValues = React.useMemo(() => {
|
||||
return Array.from({ length: childrenCount }, () => ({
|
||||
scale: motionValue(1),
|
||||
x: motionValue(0),
|
||||
}));
|
||||
}, [childrenCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
|
||||
|
||||
if (buttons.length !== childrenCount) {
|
||||
console.error(
|
||||
`Button count (${buttons.length}) does not match children count (${childrenCount})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const buttons = Array.from(
|
||||
container.querySelectorAll('.toolbar-button')
|
||||
) as HTMLElement[];
|
||||
|
||||
// Capture positions if needed
|
||||
if (originalPositionsRef.current.length !== buttons.length) {
|
||||
originalPositionsRef.current = captureButtonPositions(buttons);
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const { clientX: mouseX, clientY: mouseY } = event;
|
||||
|
||||
// Check if mouse is in range
|
||||
const isInRange =
|
||||
mouseX >= containerRect.left - finalConfig.padding &&
|
||||
mouseX <= containerRect.right + finalConfig.padding &&
|
||||
mouseY >= containerRect.top - finalConfig.padding &&
|
||||
mouseY <= containerRect.bottom + finalConfig.padding;
|
||||
|
||||
if (!isInRange) {
|
||||
resetMotionValues(buttonMotionValues);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate scales for all buttons
|
||||
const buttonEffects = buttons.map((_, index) => {
|
||||
const pos = originalPositionsRef.current[index];
|
||||
if (!pos) return { scale: 1, translateX: 0 };
|
||||
|
||||
const buttonCenterX = pos.left + pos.width / 2;
|
||||
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
|
||||
return { scale, translateX: 0 };
|
||||
});
|
||||
|
||||
// Calculate spacing for all buttons
|
||||
buttonEffects.forEach((effect, index) => {
|
||||
effect.translateX = calculateSpacing(
|
||||
index,
|
||||
buttonEffects,
|
||||
originalPositionsRef.current,
|
||||
finalConfig
|
||||
);
|
||||
});
|
||||
|
||||
// Update motion values
|
||||
buttonEffects.forEach((effect, index) => {
|
||||
const motionValue = buttonMotionValues[index];
|
||||
if (motionValue) {
|
||||
motionValue.scale.set(effect.scale);
|
||||
motionValue.x.set(effect.translateX);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
|
||||
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
};
|
||||
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
|
||||
|
||||
return { buttonMotionValues };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
getSessionStorageItem,
|
||||
removeSessionStorageItem,
|
||||
setLocalStorageItem,
|
||||
setSessionStorageItem,
|
||||
} from '@/lib/browser/local-storage';
|
||||
|
||||
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
||||
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
||||
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
||||
|
||||
type SessionHideReason = 'session' | 'persistent';
|
||||
|
||||
/**
|
||||
* Read the current session hide state. We store whether the toolbar was hidden “for session” or
|
||||
* “persistently” so we can distinguish between a temporary dismissal and a user preference.
|
||||
*/
|
||||
export const getSessionHideState = (): { hidden: boolean; reason?: SessionHideReason } => {
|
||||
const value = getSessionStorageItem<SessionHideReason | null>(SESSION_STORAGE_KEY, null);
|
||||
if (value === 'session' || value === 'persistent') {
|
||||
return { hidden: true, reason: value };
|
||||
}
|
||||
return { hidden: false, reason: undefined };
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the session hide state. Passing `false` clears the stored preference entirely.
|
||||
*/
|
||||
export const setSessionHidden = (value: boolean, reason?: SessionHideReason) => {
|
||||
if (value && reason) {
|
||||
setSessionStorageItem(SESSION_STORAGE_KEY, reason);
|
||||
} else {
|
||||
removeSessionStorageItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the last minified state from session storage. If no value was ever stored we return
|
||||
* `undefined`, which signals that the toolbar should auto-expand once the logo animation finishes.
|
||||
*/
|
||||
export const getStoredMinified = (): boolean | undefined => {
|
||||
const stored = getSessionStorageItem<boolean | null>(SESSION_MINIFIED_KEY, null);
|
||||
if (stored === null || stored === undefined) {
|
||||
removeSessionStorageItem(SESSION_MINIFIED_KEY);
|
||||
return undefined;
|
||||
}
|
||||
return stored;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the current minified state for the ongoing session.
|
||||
*/
|
||||
export const setStoredMinified = (value: boolean) => {
|
||||
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
||||
};
|
||||
|
||||
interface UseToolbarVisibilityOptions {
|
||||
onPersistentClose?: () => void;
|
||||
onSessionClose?: () => void;
|
||||
onToggleMinify?: () => void;
|
||||
}
|
||||
|
||||
export function useToolbarVisibility(options: UseToolbarVisibilityOptions = {}) {
|
||||
const { onPersistentClose, onSessionClose, onToggleMinify } = options;
|
||||
|
||||
const [initialStoredMinified] = React.useState<boolean | undefined>(() =>
|
||||
typeof window !== 'undefined' ? getStoredMinified() : undefined
|
||||
);
|
||||
const shouldAutoExpand = initialStoredMinified === undefined;
|
||||
|
||||
const [minified, setMinifiedState] = React.useState<boolean>(() =>
|
||||
initialStoredMinified !== undefined ? initialStoredMinified : true
|
||||
);
|
||||
|
||||
const [persistentHidden, setPersistentHidden] = React.useState(() =>
|
||||
getLocalStorageItem(STORAGE_KEY, false)
|
||||
);
|
||||
|
||||
const [sessionReason, setSessionReason] = React.useState<SessionHideReason | undefined>(() => {
|
||||
const state = getSessionHideState();
|
||||
return state.hidden ? state.reason : undefined;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStorage = () => {
|
||||
setPersistentHidden(getLocalStorageItem(STORAGE_KEY, false));
|
||||
const state = getSessionHideState();
|
||||
setSessionReason(state.hidden ? state.reason : undefined);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (persistentHidden) {
|
||||
if (sessionReason !== 'persistent') {
|
||||
// Sync the session flag so we honour the persistent preference and avoid flashes when
|
||||
// opening new tabs in the same browsing session.
|
||||
setSessionHidden(true, 'persistent');
|
||||
setSessionReason('persistent');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionReason === 'persistent') {
|
||||
// If the persistent preference was cleared we also clear the session flag so the toolbar
|
||||
// can reappear immediately without requiring a full reload.
|
||||
setSessionHidden(false);
|
||||
setSessionReason(undefined);
|
||||
}
|
||||
}, [persistentHidden, sessionReason]);
|
||||
|
||||
const setMinified = (value: boolean) => {
|
||||
setMinifiedState(value);
|
||||
setStoredMinified(value);
|
||||
onToggleMinify?.();
|
||||
};
|
||||
|
||||
const minimize = () => setMinified(true);
|
||||
|
||||
const closeSession = () => {
|
||||
setSessionHidden(true, 'session');
|
||||
setSessionReason('session');
|
||||
onSessionClose?.();
|
||||
};
|
||||
|
||||
const closePersistent = () => {
|
||||
setLocalStorageItem(STORAGE_KEY, true);
|
||||
setPersistentHidden(true);
|
||||
setSessionHidden(true, 'persistent');
|
||||
setSessionReason('persistent');
|
||||
onPersistentClose?.();
|
||||
};
|
||||
|
||||
const hidden = persistentHidden || sessionReason === 'session';
|
||||
|
||||
return {
|
||||
minified,
|
||||
setMinified,
|
||||
shouldAutoExpand,
|
||||
hidden,
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
'use server';
|
||||
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
|
||||
/**
|
||||
* Return true if a change-request has been updated.
|
||||
* Return true if the content has been updated.
|
||||
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
|
||||
*/
|
||||
export async function hasContentBeenUpdated(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: props.spaceId,
|
||||
changeRequestId: props.changeRequestId,
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
if (siteURLData.changeRequest) {
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: siteURLData.space,
|
||||
changeRequestId: siteURLData.changeRequest,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
}
|
||||
|
||||
const space = await getDataOrNull(
|
||||
context.dataFetcher.getSpace({
|
||||
spaceId: siteURLData.space,
|
||||
shareKey: siteURLData.shareKey,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
if (!space) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
return space.revision !== props.revisionId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,20 +5,71 @@ import React from 'react';
|
||||
import { hasContentBeenUpdated } from './server-actions';
|
||||
|
||||
/**
|
||||
* Return a callback to check if a change request has been updated and to refresh the page if it has.
|
||||
* Return a callback to check if a content has been updated and to refresh the page if it has.
|
||||
*/
|
||||
export function useCheckForContentUpdate(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
/**
|
||||
* ID of the revision of the content currently being displayed.
|
||||
*/
|
||||
revisionId: string;
|
||||
/**
|
||||
* How long after page load to check for updates.
|
||||
* @default 5min
|
||||
*/
|
||||
checkAfterLoad?: number;
|
||||
}) {
|
||||
const { spaceId, changeRequestId, revisionId } = props;
|
||||
|
||||
return React.useCallback(async () => {
|
||||
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
|
||||
const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
|
||||
const [updated, setUpdated] = React.useState(false);
|
||||
|
||||
const checkForUpdates = React.useCallback(async () => {
|
||||
if (updated) {
|
||||
// No need to check for updates if we already know the content has been updated
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await hasContentBeenUpdated({ revisionId });
|
||||
setUpdated(result);
|
||||
return result;
|
||||
}, [revisionId, updated]);
|
||||
|
||||
const refreshForUpdates = React.useCallback(async () => {
|
||||
if (await checkForUpdates()) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [spaceId, changeRequestId, revisionId]);
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Check for updates after the page has loaded
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// We check only if the tab is visible; otherwise we'll check when the tab is focus again
|
||||
checkForUpdates();
|
||||
}
|
||||
}, checkAfterLoad);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [checkForUpdates, checkAfterLoad]);
|
||||
|
||||
// Check for updates when the tab is becoming visible
|
||||
React.useEffect(() => {
|
||||
if (updated) {
|
||||
// No need to check for updates if we already know the content has been updated
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdates();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', callback);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', callback);
|
||||
};
|
||||
}, [checkForUpdates, updated]);
|
||||
|
||||
return { refreshForUpdates, updated };
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style={['space-y-4']}
|
||||
style={['contents']}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,84 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import type React from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Tooltip } from '@/components/primitives';
|
||||
|
||||
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
|
||||
const { children, body } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
data-testid="annotation-button"
|
||||
aria-label={tString(language, 'annotation_button_label')}
|
||||
className={tcls(
|
||||
'decoration-dotted',
|
||||
'decoration-1',
|
||||
'underline',
|
||||
'underline-offset-2'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</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>
|
||||
<Tooltip
|
||||
label={body}
|
||||
contentProps={{
|
||||
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"
|
||||
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
|
||||
tabIndex={0}
|
||||
>
|
||||
{children}
|
||||
</dfn>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SkeletonImage,
|
||||
SkeletonParagraph,
|
||||
SkeletonSmall,
|
||||
SkeletonUpdate,
|
||||
} from '@/components/primitives';
|
||||
import type { ClassValue } from '@/lib/tailwind';
|
||||
|
||||
@@ -34,6 +35,8 @@ import { Stepper } from './Stepper';
|
||||
import { StepperStep } from './StepperStep';
|
||||
import { Table } from './Table';
|
||||
import { Tabs } from './Tabs';
|
||||
import { Update } from './Update';
|
||||
import { Updates } from './Updates';
|
||||
|
||||
export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps {
|
||||
block: Block;
|
||||
@@ -105,6 +108,10 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
return <Stepper {...props} block={block} />;
|
||||
case 'stepper-step':
|
||||
return <StepperStep {...props} block={block} />;
|
||||
case 'updates':
|
||||
return <Updates {...props} block={block} />;
|
||||
case 'update':
|
||||
return <Update {...props} block={block} />;
|
||||
case 'if':
|
||||
// If block should be processed by the API.
|
||||
return null;
|
||||
@@ -165,10 +172,13 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'images':
|
||||
case 'drawing':
|
||||
return <SkeletonImage id={id} style={style} />;
|
||||
case 'updates':
|
||||
return <SkeletonUpdate id={id} style={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
case 'column':
|
||||
case 'update':
|
||||
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
|
||||
default:
|
||||
return nullIfNever(block);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { type DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
import {
|
||||
type ContentRef,
|
||||
type DocumentBlockContentRef,
|
||||
SiteInsightsLinkPosition,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { Card } from '@/components/primitives';
|
||||
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
|
||||
import { Card, type CardProps } from '@/components/primitives';
|
||||
import {
|
||||
type ResolvedContentRef,
|
||||
resolveContentRef,
|
||||
resolveContentRefFallback,
|
||||
} from '@/lib/references';
|
||||
|
||||
import type { BlockProps } from './Block';
|
||||
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
|
||||
|
||||
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
|
||||
const { block, context, style } = props;
|
||||
@@ -16,51 +25,45 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
|
||||
: null;
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
const fallback = resolveContentRefFallback(block.data.ref);
|
||||
if (!fallback) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NotFoundRefHoverCard context={context}>
|
||||
<BlockContentRefCard
|
||||
contentRef={block.data.ref}
|
||||
resolved={fallback}
|
||||
style={style}
|
||||
/>
|
||||
</NotFoundRefHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
const isContentInOtherSpace =
|
||||
context.contentContext?.space &&
|
||||
'space' in block.data.ref &&
|
||||
context.contentContext.space.id !== block.data.ref.space;
|
||||
const kind = block?.data?.ref?.kind;
|
||||
if ((resolved.active && kind === 'space') || isContentInOtherSpace) {
|
||||
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 (
|
||||
<Card
|
||||
ref={ref}
|
||||
leadingIcon={resolved.icon ? resolved.icon : null}
|
||||
href={resolved.href}
|
||||
title={resolved.text}
|
||||
style={style}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,11 +51,13 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
|
||||
* 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`.
|
||||
* So instead we hardcode a list of blocks that can be full width. */
|
||||
const FULL_WIDTH_BLOCKS = [
|
||||
const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
|
||||
'table',
|
||||
'tabs',
|
||||
'integration',
|
||||
'openapi',
|
||||
'openapi-operation',
|
||||
'openapi-schemas',
|
||||
'openapi-webhook',
|
||||
'images',
|
||||
'embed',
|
||||
'columns',
|
||||
@@ -64,6 +66,8 @@ const FULL_WIDTH_BLOCKS = [
|
||||
'hint',
|
||||
];
|
||||
|
||||
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
|
||||
|
||||
/**
|
||||
* Renders a list of blocks without a wrapper element.
|
||||
*/
|
||||
@@ -85,11 +89,12 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
key={node.key || `${node.type}-${index}`}
|
||||
block={node}
|
||||
style={[
|
||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6 print:break-inside-avoid',
|
||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
|
||||
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user