Compare commits

..

3 Commits

Author SHA1 Message Date
Samy Pessé 295a7fab34 Changeset 2025-09-14 18:04:52 +02:00
Samy Pessé 3a23005020 Fix it 2025-09-14 12:58:12 +02:00
Samy Pessé 0b64e40d9f Expose hook useVisitorSession 2025-09-14 12:39:32 +02:00
353 changed files with 4941 additions and 12792 deletions
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
---
Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose a hook useVisitorSession to access the current user/visitor
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI basic auth placeholder
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix links to other spaces and root page in embeddable view.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Add a RSS feed to all pages with update blocks.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Remove hidden site spaces from navigation
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix hidden section not found
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI circular references
+4 -3
View File
@@ -54,9 +54,10 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally
##### Prerequisites:
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
- 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
##### Setup steps:
@@ -30,7 +30,7 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.43.0'
wranglerVersion: '4.10.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.43.0'
wranglerVersion: '4.10.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.43.0'
wranglerVersion: '4.10.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.43.0'
wranglerVersion: '4.10.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.43.0'
wranglerVersion: '4.10.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.43.0'
wranglerVersion: '4.10.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.43.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+2 -3
View File
@@ -4,9 +4,8 @@ on:
push:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -42,7 +41,7 @@ jobs:
name: Build (Open Source)
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
NPM_TOKEN_READONLY: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
+7 -16
View File
@@ -6,7 +6,7 @@ on:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -14,38 +14,29 @@ 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
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: bun run publish-all-packages
version: bun run changeset-version
publish: npm run release
version: npm 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 }}
-4
View File
@@ -25,7 +25,3 @@ yarn-error.log*
# TypeScript
*.tsbuildinfo
.npmrc
# Bun pack artifacts
packages/*/*.tgz
+1 -1
View File
@@ -1 +1 @@
v22.3
v20.6
+2 -2
View File
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >= 22.3)
- 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
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
+913 -608
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+11 -29
View File
@@ -1,27 +1,22 @@
{
"name": "gitbook",
"version": "0.1.0",
"engines": {
"node": "^22.3.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.7",
"turbo": "^2.6.1",
"vercel": "^39.4.2"
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.2.15",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"esbuild": "0.24.2"
},
"private": true,
"scripts": {
"dev": "turbo run dev --concurrency 20",
"dev": "turbo run dev",
"build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck",
@@ -31,29 +26,16 @@
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"changeset-version": "changeset version && bun run format && bun update",
"publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful",
"changeset-version": "changeset version && bun run format",
"release": "turbo run release && changeset publish",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean"
},
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"@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"
"@gitbook/api": "^0.141.0",
"bidc": "^0.0.2"
}
},
"patchedDependencies": {
-24
View File
@@ -1,29 +1,5 @@
# @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
+5 -17
View File
@@ -8,29 +8,17 @@
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.1.3",
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:"
},
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "README.md", "CHANGELOG.md"]
}
-18
View File
@@ -1,23 +1,5 @@
# @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
+7 -17
View File
@@ -4,32 +4,22 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.3.4",
"version": "0.3.1",
"dependencies": {
"@gitbook/api": "catalog:",
"assert-never": "catalog:"
"assert-never": "^1.2.1"
},
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsdown",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"dev": "tsc -w"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
-18
View File
@@ -1,23 +1,5 @@
# @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
+6 -16
View File
@@ -4,28 +4,18 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.4.3",
"version": "0.4.0",
"devDependencies": {
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsdown",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"dev": "tsc -w"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
-30
View File
@@ -1,35 +1,5 @@
# @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
+10 -27
View File
@@ -5,44 +5,27 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
"default": "./dist/index.js",
"standalone": "./dist/standalone/index.js",
"react": "./dist/react/index.js"
}
},
"sideEffects": false,
"version": "0.1.5",
"version": "0.1.1",
"dependencies": {
"@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:",
"bidc": "catalog:"
},
"peerDependencies": {
"react": "*"
"react": "^18.0.0"
},
"devDependencies": {
"@types/react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"react": "catalog:"
"typescript": "^5.5.3",
"react": "^19.0.0"
},
"scripts": {
"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"
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"typecheck": "tsc --noEmit"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
}
+6 -8
View File
@@ -1,6 +1,4 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import React from 'react';
import type {
GetFrameURLOptions,
GitBookEmbeddableConfiguration,
@@ -19,19 +17,19 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
useEffect(() => {
React.useEffect(() => {
if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current));
}
}, [gitbook]);
useEffect(() => {
React.useEffect(() => {
gitbookFrame?.configure({
buttons,
welcomeMessage,
+1 -12
View File
@@ -29,16 +29,6 @@
--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;
}
@@ -130,8 +120,7 @@
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-translucent);
backdrop-filter: blur(48px);
background-color: var(--gitbook-widget-background-solid);
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);
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: 'src/index.ts',
outDir: 'dist',
},
{
entry: 'src/react/index.ts',
outDir: 'dist/react',
unbundle: true,
},
]);
+2 -14
View File
@@ -1,25 +1,13 @@
# @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
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.2",
"version": "0.2.0",
"private": true,
"exports": "./dist/index.ts",
"sideEffects": false,
"dependencies": {},
"devDependencies": {
"emoji-assets": "^9.0.0"
"emoji-assets": "^8.0.0"
},
"scripts": {
"generate": "bun ./build.ts",
-25
View File
@@ -1,30 +1,5 @@
# @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
+11 -21
View File
@@ -1,47 +1,37 @@
{
"name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.4",
"version": "1.2.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"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",
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
"assert-never": "^1.2.1"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
"@tsconfig/node20": "catalog:",
"@types/escodegen": "^0.0.10",
"bun-types": "^1.1.20",
"tsdown": "^0.15.0",
"@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15",
"bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
"@types/escodegen": "^0.0.10"
},
"scripts": {
"build": "tsdown",
"build": "tsdown --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"clean": "rm -rf ./dist",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"clean": "rm -rf ./dist"
},
"files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
}
"files": ["dist", "README.md", "CHANGELOG.md"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["./tsconfig.json"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"declaration": true,
"noEmit": false,
"outDir": "dist"
}
}
@@ -2,7 +2,8 @@ import { defineConfig } from 'tsdown';
export default defineConfig([
{
entry: ['src/index.ts', 'src/icons.ts'],
unbundle: true,
entry: ['src/index.ts'],
dts: true,
format: ['esm'],
},
]);
-18
View File
@@ -1,23 +1,5 @@
# @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
+8 -18
View File
@@ -4,38 +4,28 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"sideEffects": false,
"version": "0.1.3",
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "catalog:"
},
"devDependencies": {
"bun-types": "catalog:",
"google-font-metadata": "^6.0.7",
"tsdown": "catalog:",
"typescript": "catalog:"
"google-font-metadata": "^6.0.3",
"typescript": "^5.5.3"
},
"scripts": {
"generate": "bun ./bin/generate.js",
"build": "tsdown",
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"dev": "tsc -w",
"clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test",
"publish-to-npm": "../../scripts/publish-if-new.sh"
"unit": "bun test"
},
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
"files": ["dist", "src", "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://bun.sh/docs/test/snapshots
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`getDefaultFont should return correct object for Latin text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should return correct object for Greek text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should handle mixed script text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should handle different font weights: regular 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different font weights: bold 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different fonts: inter 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{
"font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff",
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
}
`;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
-144
View File
@@ -1,149 +1,5 @@
# 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
+15 -10
View File
@@ -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,6 +172,11 @@ 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',
+34 -164
View File
@@ -33,35 +33,9 @@ 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',
@@ -78,9 +52,7 @@ const searchTestCases: Test[] = [
// Fill search input, expecting search results
await searchInput.fill('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId('search-results')).toBeVisible();
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();
@@ -125,6 +97,9 @@ 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({
@@ -132,12 +107,16 @@ 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({
@@ -145,32 +124,25 @@ 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();
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
// 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
// Fill search input, expecting AI search option
await searchInput.fill(AI_PROMPT);
await searchInput.fill('What is gitbook?');
const aiSearchResult = page.getByTestId('search-ask-question');
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);
},
},
{
@@ -180,12 +152,11 @@ 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);
},
},
{
@@ -200,8 +171,6 @@ 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);
},
},
{
@@ -211,13 +180,12 @@ 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')).toBeEmpty();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
},
},
{
@@ -226,18 +194,17 @@ const searchTestCases: Test[] = [
ai: {
mode: CustomizationAIMode.Assistant,
},
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
})}&ask=What+is+GitBook%3F`,
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('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')).toBeVisible({
timeout: 15_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
'What is GitBook?'
);
},
},
];
@@ -261,58 +228,6 @@ const testCases: TestsCase[] = [
);
},
},
{
name: 'Expandable TOC navigation',
url: '',
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Navigation" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Navigation' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// Verify "Navigation" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
{
name: 'Expandable nested TOC navigation',
url: '',
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Spaces" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Spaces' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// At this stage the link should still not be visible
await expect(navigationLink).not.toBeVisible();
// Then we click 'Content Structure' chevron to expand further
const contentStructureChevron = page
.getByRole('link', { name: 'Content Structure' })
.locator('span');
await contentStructureChevron.click();
// Verify "Spaces" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
...searchTestCases,
{
name: 'Not found',
@@ -370,37 +285,6 @@ 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();
},
},
],
},
{
@@ -843,10 +727,7 @@ const testCases: TestsCase[] = [
url: 'blocks/integrations',
run: async (page) => {
await waitForCookiesDialog(page);
const mermaidIframe = page
.locator('iframe[title*="mermaid"]')
.first()
.contentFrame();
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
},
@@ -912,11 +793,6 @@ const testCases: TestsCase[] = [
url: 'blocks/cards',
fullPage: true,
},
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
},
{
name: 'Math',
url: 'blocks/math',
@@ -975,10 +851,7 @@ const testCases: TestsCase[] = [
{
name: 'With cover',
url: 'page-options/page-with-cover',
run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
run: waitForCookiesDialog,
},
{
name: 'With cover for dark mode',
@@ -993,18 +866,12 @@ const testCases: TestsCase[] = [
{
name: 'With hero cover',
url: 'page-options/page-with-hero-cover',
run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
run: waitForCookiesDialog,
},
{
name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc',
run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
@@ -1587,7 +1454,10 @@ const testCases: TestsCase[] = [
locale,
},
}),
run: waitForCookiesDialog,
run: async (page) => {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible();
},
})),
},
{
+1 -11
View File
@@ -143,10 +143,7 @@ export const headerLinks: CustomizationHeaderItem[] = [
export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({
// Cookies dialog may take some times to appear
timeout: 10_000,
});
await expect(dialog).toBeVisible();
}
export async function waitForNotFound(_page: Page, response: Response | null) {
@@ -154,13 +151,6 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404);
}
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.
*/
+2 -4
View File
@@ -7,8 +7,7 @@ export default {
converter: 'edge',
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/server').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
@@ -19,8 +18,7 @@ export default {
converter: 'edge',
proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () =>
import('./openNext/incrementalCache/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
},
},
@@ -1,13 +1,15 @@
{
"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,12 +2,14 @@
"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,10 +26,8 @@ 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(middlewareRequest, this.env, this.ctx);
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
if (reqOrResp instanceof Response) {
const duration = Date.now() - startTime;
const logMessage = formatLog(
@@ -2,7 +2,6 @@
"main": "middleware.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
@@ -12,6 +11,9 @@
"directory": "../../.open-next/assets",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
@@ -18,12 +18,15 @@ export type KeyOptions = {
/**
*
* 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.
* 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.
*/
export class GitbookIncrementalCache implements IncrementalCache {
class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache';
protected localCache: Cache | undefined;
async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
@@ -31,6 +34,7 @@ export 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
@@ -38,17 +42,27 @@ export 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: json,
lastModified,
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
});
} catch (e) {
console.error('Failed to get from cache', e);
@@ -75,8 +89,39 @@ export 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);
}
@@ -86,10 +131,14 @@ export 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);
}
@@ -119,6 +168,12 @@ export 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;
@@ -134,4 +189,28 @@ export 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();
@@ -1,11 +0,0 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -1,12 +0,0 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
+19 -31
View File
@@ -1,25 +1,20 @@
{
"name": "gitbook",
"version": "0.20.0",
"version": "0.18.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:*",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.11.0",
"@opennextjs/cloudflare": "^1.7.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -31,17 +26,19 @@
"@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2",
"assert-never": "catalog:",
"assert-never": "^1.2.1",
"bidc": "catalog:",
"classnames": "catalog:",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1",
"event-iterator": "^2.0.0",
"feed": "^5.1.0",
"framer-motion": "^10.16.14",
"image-size": "^2.0.2",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0",
"mcp-handler": "^1.0.2",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
@@ -49,17 +46,14 @@
"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.4.6",
"next-themes": "^0.2.1",
"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",
@@ -75,15 +69,16 @@
"unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0",
"url-join": "^5.0.0",
"usehooks-ts": "catalog:",
"usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1",
"zod": "^3",
"zustand": "^5.0.3"
"zustand": "^5.0.3",
"mcp-handler": "^1.0.2",
"@modelcontextprotocol/sdk": "^1.17.5",
"zod": "^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",
@@ -92,10 +87,9 @@
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "18.3.13",
"@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9",
"bun-types": "catalog:",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2",
@@ -103,10 +97,8 @@
"stylelint": "^16.16.0",
"tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1",
"typescript": "catalog:",
"vercel": "^39.3.0",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
"typescript": "^5.5.3",
"vercel": "^39.3.0"
},
"scripts": {
"generate": "./scripts/generate.sh",
@@ -127,9 +119,5 @@
},
"browserslist": [
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
]
}
@@ -22,11 +22,7 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers());
return (
<CustomizationRootLayout
className="site-background"
forcedTheme={forcedTheme}
context={context}
>
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
@@ -1,7 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
export const dynamic = 'force-static';
export default async function Page() {
return <EmbeddableAssistantPage />;
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} />;
}
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents();
return (
<CustomizationRootLayout className="site-background" context={context}>
<CustomizationRootLayout context={context}>
<SiteLayout
context={context}
withTracking={withTracking}
@@ -1,7 +1,16 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
export const dynamic = 'force-static';
export default async function Page() {
return <EmbeddableAssistantPage />;
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} />;
}
@@ -16,107 +16,9 @@ export async function GET(
return new Response(
`
<html>
<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>
<head></head>
<body></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>
`,
{
@@ -1,12 +0,0 @@
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
import { servePageRSS } from '@/routes/rss';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageRSS(context, pathname);
}
@@ -16,14 +16,14 @@ export function AIMessageView(
) {
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return message.steps.length > 0 ? (
return (
<div className="flex flex-col gap-2">
{message.steps.map((step, index) => {
return (
<div
key={index}
className={tcls(
'flex flex-col gap-2',
'flex animate-fade-in-slow flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : ''
)}
>
@@ -35,7 +35,7 @@ export function AIMessageView(
wrapBlocksInSuspense: false,
withLinkPreviews,
}}
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
style="mt-2 space-y-4 empty:hidden"
/>
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
@@ -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="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon
icon={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="-ml-5 group flex w-full flex-col">
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
<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-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => (
<li
+13 -4
View File
@@ -1,14 +1,14 @@
'use client';
import { CustomizationAIMode } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import * as React from 'react';
import type { ReactNode } from 'react';
import { tString, useLanguage } from '@/intl/client';
import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { AIChatIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch';
@@ -104,7 +104,16 @@ export function useAI(): AIContext {
assistants.push({
id: 'gitbook-ai-search',
label: tString(language, 'ai_chat_context_badge'),
icon: <AISearchIcon />,
icon: (
<div className="relative">
<Icon icon="search" className="size-4" />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
className="absolute top-[2.5px] left-[2.6px] size-2"
/>
</div>
),
open: (query?: string) => {
if (query) {
setSearchState((prev) =>
@@ -128,7 +137,7 @@ export function useAI(): AIContext {
setSearchState((prev) => ({
ask: null, // Reset ask as we assume the assistant will handle it
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
global: prev?.global ?? false,
open: false,
}));
assistant.open(query);
@@ -146,7 +146,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
global: prev?.global ?? false,
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,
scope: prev?.scope ?? 'default',
global: prev?.global ?? false,
open: false,
}));
}, [setSearchState]);
@@ -374,7 +374,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
global: prev?.global ?? false,
open: false,
}));
}
@@ -435,7 +435,7 @@ export function AIChatProvider(props: {
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
global: prev?.global ?? false,
open: false,
}));
}, [setSearchState]);
@@ -2,14 +2,12 @@
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';
@@ -25,15 +23,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() {
const { config } = useAI();
export function AIChat(props: { trademark: boolean }) {
const { trademark } = props;
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
@@ -67,22 +65,21 @@ export function AIChat() {
}
}, [chat.opened, trackEvent]);
if (!chat.opened) {
return null;
}
return (
<div
data-testid="ai-chat"
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'
)}
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"
>
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={config.trademark} />
<AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
{getAIChatName(language, trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
@@ -99,7 +96,7 @@ export function AIChat() {
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody chatController={chatController} chat={chat} />
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
</EmbeddableFrameBody>
</EmbeddableFrame>
</div>
@@ -146,33 +143,10 @@ export function AIChatSubtitle(props: {
const language = useLanguage();
return (
<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 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>
);
}
@@ -183,13 +157,20 @@ export function AIChatSubtitle(props: {
export function AIChatBody(props: {
chatController: AIChatController;
chat: AIChatState;
trademark: boolean;
welcomeMessage?: string;
suggestions?: string[];
}) {
const { chatController, chat, suggestions } = props;
const { trademark } = useAI().config;
const { chatController, chat, trademark, suggestions } = props;
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
@@ -203,43 +184,67 @@ 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 (
<>
<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}`}
<div
ref={scrollContainerRef}
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
style={{
paddingBottom: `${inputHeight}px`,
}}
>
{isEmpty ? (
<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 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>
{!chat.error ? (
<AIChatSuggestedQuestions
@@ -249,11 +254,17 @@ export function AIChatBody(props: {
) : null}
</div>
) : (
<AIChatMessages chat={chat} chatController={chatController} />
<AIChatMessages
chat={chat}
chatController={chatController}
lastUserMessageRef={lastUserMessageRef}
/>
)}
</ScrollContainer>
<div className="flex flex-col gap-2 px-4 pb-4">
</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"
>
{/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -1,6 +1,7 @@
'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';
@@ -23,7 +24,7 @@ export function AIChatButton(props: {
iconOnly={!showLabel}
size="medium"
variant="header"
className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden"
className={tcls('h-9 px-2.5')}
label={
<div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)}
@@ -37,7 +38,7 @@ export function AIChatButton(props: {
}
onClick={() => assistant.open()}
>
{showLabel ? t(language, 'ask') : null}
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
</Button>
);
}
@@ -1,5 +1,5 @@
import { tcls } from '@/lib/tailwind';
import { Icon, IconStyle } from '@gitbook/icons';
import { Icon } from '@gitbook/icons';
import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
@@ -9,18 +9,17 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
trademark?: boolean;
}
export function AIChatIcon({
export const 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') &&
@@ -164,36 +163,4 @@ export function 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,7 +3,6 @@ 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';
@@ -20,7 +19,6 @@ export function AIChatInput(props: {
const { value, onChange, onSubmit, disabled, loading } = props;
const language = useLanguage();
const chat = useAIChatState();
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -34,7 +32,7 @@ export function AIChatInput(props: {
};
useEffect(() => {
if (chat.opened && !disabled && !loading) {
if (!disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => {
@@ -43,7 +41,7 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout);
}
}, [disabled, loading, chat.opened]);
}, [disabled, loading]);
useHotkeys(
'mod+i',
@@ -57,7 +55,7 @@ export function AIChatInput(props: {
);
return (
<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">
<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">
<textarea
ref={inputRef}
disabled={disabled || loading}
@@ -108,7 +106,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 openDelay={500}>
<HoverCardRoot>
<HoverCard
className="max-w-xs bg-tint p-2 text-sm text-tint"
arrow={{ className: 'fill-tint-3' }}
@@ -142,7 +140,7 @@ export function AIChatInput(props: {
<span className="leading-none">
{t(language, 'ai_chat_context_title')}
</span>
<Icon icon="question-circle" className="size-3 shrink-0" />
<Icon icon="question-circle" className="size-3" />
</div>
</HoverCardTrigger>
</HoverCardRoot>
@@ -2,6 +2,7 @@ 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';
@@ -10,133 +11,87 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: {
chat: AIChatState;
chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
}) {
const { chat, chatController } = props;
const { chat, chatController, lastUserMessageRef } = props;
// Group messages: user messages start a new group, all following messages until next user message belong to that group
type Message = (typeof chat.messages)[0];
type MessageGroup = { message: Message; originalIndex: number };
const messageGroups: Array<Array<MessageGroup>> = [];
let currentGroup: Array<MessageGroup> = [];
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);
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 });
}
});
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}
// Add the last group if it exists
if (currentGroup.length > 0) {
messageGroups.push(currentGroup);
}
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!message.content ? <HoldMessage /> : null}
<LoadingSkeleton />
</div>
) : null}
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}
{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}
</div>
);
})}
</div>
);
});
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/>
</>
) : null}
</div>
);
})}
</>
);
}
export function HoldMessage({
breakLines = false,
className,
}: { breakLines?: boolean; className?: string }) {
function HoldMessage() {
const language = useLanguage();
return (
<div
className={tcls(
'animate-[heightIn_.5s_5s_ease_both] overflow-hidden py-2 text-tint-subtle [&.hidden]:animate-[heightOut_1s_ease-in_both]',
className
)}
>
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
{tString(language, 'ai_chat_hold_message_1')
.split(' ')
.map((word, index) => (
<span
key={index}
className="animate-blur-in-slow"
className="animate-fade-in-slow"
style={{
animationDelay: `${5000 + index * 200}ms`,
}}
@@ -144,13 +99,12 @@ export function HoldMessage({
{word}{' '}
</span>
))}
{breakLines ? <br /> : null}
{tString(language, 'ai_chat_hold_message_2')
.split(' ')
.map((word, index) => (
<span
key={index}
className="animate-blur-in-slow"
className="animate-fade-in-slow"
style={{
animationDelay: `${10000 + index * 200}ms`,
}}
@@ -168,9 +122,9 @@ function LoadingSkeleton() {
{Array.from({ length: 7 }).map((_, index) => (
<div
key={index}
className="h-4 animate-[blurIn_500ms_ease-out_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2"
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
style={{
width: `calc(${(4 - (index % 4)) * 8 + 14}% - 4px)`,
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
animationDelay: `${index * 0.1}s`,
}}
/>
@@ -17,15 +17,15 @@ export default function AIChatSuggestedQuestions(props: {
} = props;
return (
<div className="flex flex-col items-start gap-2 self-start">
<div className="flex flex-col items-center gap-2">
{suggestions.map((question, index) => (
<Button
key={question}
variant="blank"
variant="secondary"
size="medium"
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 px-3 py-1.5 hover:bg-primary-hover"
className="max-w-full animate-[present_500ms_both] whitespace-normal"
style={{
animationDelay: `${1000 + index * 100}ms`,
animationDelay: `${800 + 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 empty:hidden">
<div className="flex w-full flex-wrap justify-end gap-2">
{chat.pendingTools.map((tool, index) => (
<div
className="flex w-full animate-present-slow flex-col gap-1"
@@ -24,9 +24,7 @@ export function AIResponseFeedback(props: {
};
return (
<div
className={tcls('flex h-fit animate-[heightIn_.5s_ease_both] items-center', className)}
>
<div className={tcls('flex h-fit items-center', className)}>
<Button
icon="thumbs-up"
iconOnly
@@ -37,7 +35,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
)}
size="medium"
style={{ animationDelay: '.5s', animationDuration: '.5s' }}
style={{ animationDuration: '.5s' }}
onClick={() => handleRating(1)}
disabled={rating !== null}
active={rating === 1}
@@ -53,7 +51,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
)}
size="medium"
style={{ animationDelay: '.7s', animationDuration: '.5s' }}
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
onClick={() => handleRating(-1)}
disabled={rating !== null}
active={rating === -1}
@@ -61,8 +59,8 @@ export function AIResponseFeedback(props: {
/>
{rating !== null ? (
<span
className="ml-2 animate-blur-in-slow text-tint-subtle"
style={{ animationDelay: '.5s' }}
className="ml-2 animate-fade-in-slow text-tint-subtle"
style={{ animationDelay: '.3s' }}
>
{t(language, 'was_this_helpful_thank_you')}
</span>
@@ -15,28 +15,22 @@ export function AIChatFollowupSuggestions(props: {
}
return (
<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 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>
);
}
@@ -4,14 +4,19 @@ import type { GitBookSiteContext } from '@/lib/context';
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { createContext, useContext } from 'react';
import type { AdaptiveVisitorClaims } from './types';
export type AdaptiveVisitorClaimsData = {
visitor: {
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
};
};
/**
* In-memory cache of visitor claim readers keyed by contextId.
*/
const adaptiveVisitorReaderCache = new Map<
string,
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
>();
function createResourceReader<T>(promise: Promise<T>) {
@@ -47,7 +52,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
if (!res.ok) {
return null;
}
return await res.json<AdaptiveVisitorClaims>();
return await res.json<AdaptiveVisitorClaimsData>();
} catch {
return null;
}
@@ -59,7 +64,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
return reader;
}
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
@@ -1,3 +1 @@
export * from './types';
export * from './utils';
export * from './AdaptiveVisitorContextProvider';
@@ -1,9 +0,0 @@
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
unsigned: Record<string, unknown>;
};
export type AdaptiveVisitorClaims = {
visitor: {
claims: AdaptiveVisitorClaimsData;
};
};
@@ -1,28 +0,0 @@
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,63 +1,143 @@
import type { GitBookSiteContext } from '@/lib/context';
import { AdminToolbarClient } from './AdminToolbarClient';
import type { AdminToolbarContext } from './types';
import { Icon } from '@gitbook/icons';
import React from 'react';
export interface AdminToolbarProps {
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 {
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>
);
}
/**
* Server component that determines what type of toolbar to show and passes data to client component
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { context } = props;
// 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.changeRequest) {
return (
<IframeWrapper>
<ChangeRequestToolbar context={context} />
</IframeWrapper>
);
}
return <AdminToolbarClient context={minimalContext} />;
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>
);
}
@@ -1,391 +0,0 @@
'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();
}
@@ -1,185 +0,0 @@
.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;
}
}
@@ -1,68 +0,0 @@
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';
@@ -1,233 +0,0 @@
'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>
);
}
@@ -0,0 +1,70 @@
'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>
);
}
@@ -1,42 +0,0 @@
'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}
/>
);
}
@@ -1,40 +0,0 @@
.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,347 +1,65 @@
'use client';
import {
AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import type * as React from 'react';
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';
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 (
<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 items-start px-3">{props.children}</div>;
}
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
export function Toolbar(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,
});
return (
<motion.div
ref={containerRef}
variants={toolbarEasings.parent}
initial="hidden"
animate="show"
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
<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'
)}
>
{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} />
{children}
</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>
);
export function ToolbarBody(props: { children: React.ReactNode }) {
return <div className="flex flex-col gap-1">{props.children}</div>;
}
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 ToolbarButtonGroups(props: { children: React.ReactNode }) {
return <div className="flex flex-row gap-2">{props.children}</div>;
}
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
const { children, ...rest } = props;
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
<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'
)}
>
{props.subtitle}
</motion.span>
{children}
</a>
);
}
@@ -1,27 +0,0 @@
'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,6 +1,2 @@
export * from './AdminToolbar';
export * from './AdminToolbarClient';
export * from './IframeWrapper';
export * from './Toolbar';
export * from './transitions';
export * from './utils';
@@ -1,73 +0,0 @@
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,
};
@@ -1,60 +0,0 @@
/*
* 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;
}
@@ -1,237 +0,0 @@
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 };
}
@@ -1,154 +0,0 @@
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,43 +1,28 @@
'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 the content has been updated.
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
* Return true if a change-request has been updated.
*/
export async function hasContentBeenUpdated(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
}) {
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
const context = await getServerActionBaseContext();
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,
const changeRequest = await getDataOrNull(
context.dataFetcher.getChangeRequest({
spaceId: props.spaceId,
changeRequestId: props.changeRequestId,
})
);
if (!space) {
if (!changeRequest) {
return false;
}
return space.revision !== props.revisionId;
return changeRequest.revision !== props.revisionId;
});
}
@@ -5,71 +5,20 @@ import React from 'react';
import { hasContentBeenUpdated } from './server-actions';
/**
* Return a callback to check if a content has been updated and to refresh the page if it has.
* Return a callback to check if a change request has been updated and to refresh the page if it has.
*/
export function useCheckForContentUpdate(props: {
/**
* ID of the revision of the content currently being displayed.
*/
spaceId: string;
changeRequestId: string;
revisionId: string;
/**
* How long after page load to check for updates.
* @default 5min
*/
checkAfterLoad?: number;
}) {
const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
const [updated, setUpdated] = React.useState(false);
const { spaceId, changeRequestId, revisionId } = props;
return React.useCallback(async () => {
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
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();
}
}, [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 };
}, [spaceId, changeRequestId, revisionId]);
}
@@ -27,7 +27,7 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style={['contents']}
style={['space-y-4']}
/>
}
>
@@ -1,30 +1,84 @@
'use client';
import * as Popover from '@radix-ui/react-popover';
import type React from 'react';
import { Tooltip } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
const { children, body } = props;
const language = useLanguage();
return (
<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>
<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>
);
}
@@ -6,7 +6,6 @@ import {
SkeletonImage,
SkeletonParagraph,
SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind';
@@ -35,8 +34,6 @@ 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;
@@ -108,10 +105,6 @@ 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;
@@ -172,13 +165,10 @@ 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,18 +1,9 @@
import {
type ContentRef,
type DocumentBlockContentRef,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { type DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api';
import { Card, type CardProps } from '@/components/primitives';
import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
} from '@/lib/references';
import { Card } from '@/components/primitives';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props;
@@ -25,45 +16,51 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
: null;
if (!resolved) {
const fallback = resolveContentRefFallback(block.data.ref);
if (!fallback) {
return null;
}
return (
<NotFoundRefHoverCard context={context}>
<BlockContentRefCard
contentRef={block.data.ref}
resolved={fallback}
style={style}
/>
</NotFoundRefHoverCard>
);
return null;
}
return <BlockContentRefCard contentRef={block.data.ref} resolved={resolved} style={style} />;
}
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} />;
}
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: contentRef,
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
{...rest}
/>
);
}
async function SpaceRefCard(
props: { resolved: ResolvedContentRef } & BlockProps<DocumentBlockContentRef>
) {
const { context, style, resolved } = props;
const spaceId = context.contentContext?.space.id;
if (!spaceId) {
return null;
}
return (
<Card
href={resolved.href}
title={resolved.text}
postTitle={resolved.subText}
style={style}
/>
);
}

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