Compare commits

..

1 Commits

Author SHA1 Message Date
Zeno Kapitein 38226b9413 Simplify AI Chat
- Add new `note` field
- Always clear `messages` when posting a message
- Remove follow-up questions
2025-09-16 16:29:10 +02:00
208 changed files with 2393 additions and 5969 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Simplify AI Chat
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix robots.txt preventing favicon from being indexed.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Show a toolbar for authenticated users with access to the current site.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Scope search across sections and variants
+1 -1
View File
@@ -54,7 +54,7 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally #### 3. Install dependencies and run the project locally
##### Prerequisites: ##### Prerequisites:
- Node.js (Version: >= 22.3) - Node.js (Version: >=20.6)
- Use `nvm` for easy Node management - Use `nvm` for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15 - We use a text-based lockfile which isn't supported below 1.2.15
+2 -2
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -41,7 +41,7 @@ jobs:
name: Build (Open Source) name: Build (Open Source)
timeout-minutes: 6 timeout-minutes: 6
env: env:
BUN_NPM_TOKEN: '' NPM_TOKEN_READONLY: ''
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1 -1
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (preview) name: Deploy v2 to Vercel (preview)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (production) name: Deploy v2 to Vercel (production)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (staging) name: Deploy v2 to Vercel (staging)
+2 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -36,6 +36,7 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created # Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-1
View File
@@ -25,4 +25,3 @@ yarn-error.log*
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
.npmrc
+1 -1
View File
@@ -1 +1 @@
v22.3 v20.6
+2 -2
View File
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites ### Prerequisites
- Node.js (Version: >= 22.3) - Node.js (Version: >=20.6)
- Use nvm for easy Node management - Use nvm for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15 - We use a text-based lockfile which isn't supported below 1.2.15
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
``` ```
.env.local .env.local
BUN_NPM_TOKEN=xxx NPM_TOKEN_READONLY=xxx
``` ```
and then reinstall dependencies. and then reinstall dependencies.
+299 -350
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes] [install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" } "gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+10 -19
View File
@@ -3,20 +3,20 @@
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.7", "@changesets/cli": "^2.27.12",
"turbo": "^2.5.8", "turbo": "^2.5.0",
"vercel": "^39.4.2" "vercel": "^39.3.0"
}, },
"packageManager": "bun@1.2.15", "packageManager": "bun@1.2.15",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"react": "catalog:", "react": "^19.0.0",
"react-dom": "catalog:", "react-dom": "^19.0.0",
"esbuild": "0.24.2" "esbuild": "0.24.2"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run dev --concurrency 20", "dev": "turbo run dev",
"build": "turbo run build", "build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules", "clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
@@ -26,25 +26,16 @@
"e2e": "turbo run e2e", "e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers", "e2e-customers": "turbo run e2e-customers",
"changeset": "changeset", "changeset": "changeset",
"changeset-version": "changeset version && bun run format && bun update", "changeset-version": "changeset version && bun run format",
"release": "turbo run release && bun run publish-all-packages", "release": "turbo run release && changeset publish",
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean" "clean": "turbo run clean"
}, },
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@gitbook/api": "0.143.2", "@gitbook/api": "^0.141.0",
"assert-never": "^1.4.0", "bidc": "^0.0.2"
"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.0"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
-16
View File
@@ -1,21 +1,5 @@
# @gitbook/browser-types # @gitbook/browser-types
## 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 ## 0.1.0
### Minor Changes ### Minor Changes
+5 -12
View File
@@ -8,24 +8,17 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.1.0",
"version": "0.1.2",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:" "@gitbook/icons": "workspace:"
}, },
"devDependencies": { "devDependencies": {
"tsdown": "catalog:", "typescript": "^5.5.3"
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit"
"dev": "bun run build -- --watch ./src"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
} }
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/cache-tags # @gitbook/cache-tags
## 0.3.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.3.2
### Patch Changes
- 295f03d: Republish packages
## 0.3.1 ## 0.3.1
### Patch Changes ### Patch Changes
+7 -12
View File
@@ -4,27 +4,22 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.3.1",
"version": "0.3.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"assert-never": "catalog:" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"tsdown": "catalog:", "typescript": "^5.5.3"
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src" "dev": "tsc -w"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "src", "README.md", "CHANGELOG.md"]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
} }
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/colors # @gitbook/colors
## 0.4.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.4.1
### Patch Changes
- 295f03d: Republish packages
## 0.4.0 ## 0.4.0
### Minor Changes ### Minor Changes
+6 -11
View File
@@ -4,23 +4,18 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.4.0",
"version": "0.4.2",
"devDependencies": { "devDependencies": {
"tsdown": "catalog:", "typescript": "^5.5.3"
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src" "dev": "tsc -w"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "src", "README.md", "CHANGELOG.md"]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
} }
-22
View File
@@ -1,27 +1,5 @@
# @gitbook/embed # @gitbook/embed
## 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 ## 0.1.1
### Patch Changes ### Patch Changes
+10 -22
View File
@@ -5,39 +5,27 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.js" "default": "./dist/index.js",
}, "standalone": "./dist/standalone/index.js",
"./react": { "react": "./dist/react/index.js"
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
} }
}, },
"sideEffects": false, "version": "0.1.1",
"version": "0.1.4",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:", "@gitbook/icons": "workspace:",
"bidc": "catalog:" "bidc": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"react": "*" "react": "^18.0.0"
}, },
"devDependencies": { "devDependencies": {
"tsdown": "catalog:", "typescript": "^5.5.3",
"typescript": "catalog:", "react": "^19.0.0"
"react": "catalog:"
}, },
"scripts": { "scripts": {
"build": "bun run build-lib && bun run build-standalone", "build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"build-lib": "tsdown", "typecheck": "tsc --noEmit"
"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"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
} }
+6 -8
View File
@@ -1,6 +1,4 @@
'use client'; import React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
GetFrameURLOptions, GetFrameURLOptions,
GitBookEmbeddableConfiguration, GitBookEmbeddableConfiguration,
@@ -19,19 +17,19 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) { export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props; const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null); const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook(); const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null); const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]); const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
useEffect(() => { React.useEffect(() => {
if (frameRef.current) { if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current)); setGitbookFrame(gitbook.createFrame(frameRef.current));
} }
}, [gitbook]); }, [gitbook]);
useEffect(() => { React.useEffect(() => {
gitbookFrame?.configure({ gitbookFrame?.configure({
buttons, buttons,
welcomeMessage, welcomeMessage,
-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 # @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 ## 0.2.0
### Minor Changes ### Minor Changes
- 57adb3e: Second release to fix publishing with changeset - 57adb3e: Second release to fix publishing with changeset
## 0.1.0 ## 0.1.0
### Minor Changes ### Minor Changes
- 5f8a8fe: Initial release - 5f8a8fe: Initial release
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@gitbook/emoji-codepoints", "name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints", "description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.2", "version": "0.2.0",
"private": true, "private": true,
"exports": "./dist/index.ts", "exports": "./dist/index.ts",
"sideEffects": false, "dependencies": {},
"devDependencies": { "devDependencies": {
"emoji-assets": "^9.0.0" "emoji-assets": "^8.0.0"
}, },
"scripts": { "scripts": {
"generate": "bun ./build.ts", "generate": "bun ./build.ts",
-18
View File
@@ -1,23 +1,5 @@
# @gitbook/expr # @gitbook/expr
## 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 ## 1.2.0
### Minor Changes ### Minor Changes
+11 -14
View File
@@ -1,40 +1,37 @@
{ {
"name": "@gitbook/expr", "name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.", "description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.3", "version": "1.2.0",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
"acorn": "^8.14.0", "acorn": "^8.14.0",
"acorn-loose": "8.4.0", "acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4", "acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0", "escodegen": "^2.1.0",
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"@types/escodegen": "^0.0.10", "bun-types": "^1.1.20",
"tsdown": "^0.15.0",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"bun-types": "catalog:", "@types/escodegen": "^0.0.10"
"tsdown": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown --project tsconfig.build.json",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"unit": "bun test", "unit": "bun test",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist"
"dev": "bun run build -- --watch ./src"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
} }
+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([ export default defineConfig([
{ {
entry: 'src/index.ts', entry: ['src/index.ts'],
unbundle: true, dts: true,
format: ['esm'],
}, },
]); ]);
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/fonts # @gitbook/fonts
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.1.1
### Patch Changes
- 295f03d: Republish packages
## 0.1.0 ## 0.1.0
### Minor Changes ### Minor Changes
+7 -12
View File
@@ -4,33 +4,28 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "version": "0.1.0",
"version": "0.1.2",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:" "@gitbook/api": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"google-font-metadata": "^6.0.7", "google-font-metadata": "^6.0.3",
"tsdown": "catalog:", "typescript": "^5.5.3"
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"generate": "bun ./bin/generate.js", "generate": "bun ./bin/generate.js",
"build": "tsdown", "build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src", "dev": "tsc -w",
"clean": "rm -rf ./dist && rm -rf ./src/data", "clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test" "unit": "bun test"
}, },
"files": ["dist", "bin", "README.md", "CHANGELOG.md"], "files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
} }
} }
@@ -1,57 +1,57 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots // Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`getDefaultFont should return correct object for Latin text 1`] = ` exports[`getDefaultFont should return correct object for Latin text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = ` exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Greek text 1`] = ` exports[`getDefaultFont should return correct object for Greek text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle mixed script text 1`] = ` exports[`getDefaultFont should handle mixed script text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: regular 1`] = ` exports[`getDefaultFont should handle different font weights: regular 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: bold 1`] = ` exports[`getDefaultFont should handle different font weights: bold 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: inter 1`] = ` exports[`getDefaultFont should handle different fonts: inter 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: roboto 1`] = ` exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{ {
"font": "Roboto", "font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff", "url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
} }
`; `;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
-89
View File
@@ -1,94 +1,5 @@
# gitbook # gitbook
## 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 ## 0.18.0
### Minor Changes ### Minor Changes
+5 -5
View File
@@ -62,11 +62,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.gmgn.ai', contentBaseURL: 'https://docs.gmgn.ai',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
// { {
// name: 'docs.spicychat.ai', name: 'docs.spicychat.ai',
// contentBaseURL: 'https://docs.spicychat.ai', contentBaseURL: 'https://docs.spicychat.ai',
// tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
// }, },
{ {
name: 'docs.portainer.io', name: 'docs.portainer.io',
contentBaseURL: 'https://docs.portainer.io', contentBaseURL: 'https://docs.portainer.io',
@@ -26,10 +26,8 @@ export default class extends WorkerEntrypoint {
async fetch(request) { async fetch(request) {
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => { return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
const startTime = Date.now(); 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 // - `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) { if (reqOrResp instanceof Response) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const logMessage = formatLog( const logMessage = formatLog(
+87 -19
View File
@@ -7,8 +7,6 @@ import type {
} from '@opennextjs/aws/types/overrides.js'; } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare'; import { getCloudflareContext } from '@opennextjs/cloudflare';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
@@ -20,12 +18,15 @@ export type KeyOptions = {
/** /**
* *
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional * It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
* 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. * 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.
*/ */
class GitbookIncrementalCache implements IncrementalCache { class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache'; name = 'GitbookIncrementalCache';
protected localCache: Cache | undefined;
async get<CacheType extends CacheEntryType = 'cache'>( async get<CacheType extends CacheEntryType = 'cache'>(
key: string, key: string,
cacheType?: CacheType cacheType?: CacheType
@@ -33,6 +34,7 @@ class GitbookIncrementalCache implements IncrementalCache {
const cacheKey = this.getR2Key(key, cacheType); const cacheKey = this.getR2Key(key, cacheType);
const r2 = getCloudflareContext().env[BINDING_NAME]; const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket'); if (!r2) throw new Error('No R2 bucket');
if (process.env.SHOULD_BYPASS_CACHE === 'true') { if (process.env.SHOULD_BYPASS_CACHE === 'true') {
// We are in a local middleware environment, we should bypass the cache // We are in a local middleware environment, we should bypass the cache
@@ -40,17 +42,27 @@ class GitbookIncrementalCache implements IncrementalCache {
return null; return null;
} }
try { 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); const r2Object = await r2.get(cacheKey);
if (!r2Object) return null; if (!r2Object) return null;
const json = (await r2Object.json()) as CacheValue<CacheType>;
const lastModified = r2Object.uploaded.getTime();
if (!json) return null;
return this.returnNullOn404({ return this.returnNullOn404({
value: json, value: await r2Object.json(),
lastModified, lastModified: r2Object.uploaded.getTime(),
}); });
} catch (e) { } catch (e) {
console.error('Failed to get from cache', e); console.error('Failed to get from cache', e);
@@ -77,8 +89,39 @@ class GitbookIncrementalCache implements IncrementalCache {
): Promise<void> { ): Promise<void> {
const cacheKey = this.getR2Key(key, cacheType); const cacheKey = this.getR2Key(key, cacheType);
const localCache = await this.getCacheInstance();
try { try {
await this.writeToR2(cacheKey, JSON.stringify(value)); 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) { } catch (e) {
console.error('Failed to set to cache', e); console.error('Failed to set to cache', e);
} }
@@ -88,10 +131,14 @@ class GitbookIncrementalCache implements IncrementalCache {
const cacheKey = this.getR2Key(key); const cacheKey = this.getR2Key(key);
const r2 = getCloudflareContext().env[BINDING_NAME]; const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket'); if (!r2) throw new Error('No R2 bucket');
try { try {
await r2.delete(cacheKey); 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) { } catch (e) {
console.error('Failed to delete from cache', e); console.error('Failed to delete from cache', e);
} }
@@ -121,6 +168,12 @@ 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 // Utility function to generate keys for R2/Cache API
getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string { getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string {
let key = initialKey; let key = initialKey;
@@ -136,13 +189,28 @@ 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 withRegionalCache(new GitbookIncrementalCache(), { export default 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,
});
+14 -18
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.19.2", "version": "0.18.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
@@ -8,15 +8,13 @@
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*",
"@gitbook/fonts": "workspace:*", "@gitbook/fonts": "workspace:*",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*", "@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "^1.17.5", "@opennextjs/cloudflare": "^1.7.1",
"@opennextjs/cloudflare": "^1.9.2",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
@@ -28,9 +26,10 @@
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2", "@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2", "ai": "^4.2.2",
"assert-never": "catalog:", "assert-never": "^1.2.1",
"bidc": "catalog:", "bidc": "catalog:",
"classnames": "catalog:", "bun-types": "^1.1.20",
"classnames": "^2.5.1",
"direction": "^2.0.1", "direction": "^2.0.1",
"event-iterator": "^2.0.0", "event-iterator": "^2.0.0",
"framer-motion": "^10.16.14", "framer-motion": "^10.16.14",
@@ -38,8 +37,8 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2", "mathjax": "^3.2.2",
"mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2", "mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1", "mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0", "mdast-util-gfm": "^3.1.0",
@@ -49,7 +48,7 @@
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.12", "motion": "^12.23.12",
"next": "15.4.0", "next": "15.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.2.1",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"object-identity": "^0.1.2", "object-identity": "^0.1.2",
@@ -71,10 +70,12 @@
"unist-util-remove": "^4.0.0", "unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-join": "^5.0.0", "url-join": "^5.0.0",
"usehooks-ts": "catalog:", "usehooks-ts": "^3.1.0",
"warn-once": "^0.1.1", "warn-once": "^0.1.1",
"zod": "^3", "zustand": "^5.0.3",
"zustand": "^5.0.3" "mcp-handler": "^1.0.2",
"@modelcontextprotocol/sdk": "^1.17.5",
"zod": "^3"
}, },
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^5.0.9", "@argos-ci/playwright": "^5.0.9",
@@ -90,7 +91,6 @@
"@types/react": "18.3.13", "@types/react": "18.3.13",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
"bun-types": "catalog:",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -98,7 +98,7 @@
"stylelint": "^16.16.0", "stylelint": "^16.16.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1", "ts-essentials": "^10.0.1",
"typescript": "catalog:", "typescript": "^5.5.3",
"vercel": "^39.3.0" "vercel": "^39.3.0"
}, },
"scripts": { "scripts": {
@@ -120,9 +120,5 @@
}, },
"browserslist": [ "browserslist": [
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead" ">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
], ]
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
} }
@@ -16,107 +16,9 @@ export async function GET(
return new Response( return new Response(
` `
<html> <html>
<head> <head></head>
<meta name="color-scheme" content="light dark"> <body></body>
</head>
<body>
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.1" x="0.566895" y="208" width="529.567" height="228" rx="16" fill="#A4A7B0"/>
<g opacity="0.2">
<line x1="141.567" y1="472" x2="4.56689" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="416.067" y1="472" x2="165.567" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="472" x2="440.067" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="194.567" y1="496" x2="4.56689" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="324.067" y1="496" x2="218.567" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="473.567" y1="496" x2="348.067" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="231.901" y1="520" x2="4.56689" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="291.186" y1="520" x2="255.901" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="442.058" y1="520" x2="315.186" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="520" x2="466.058" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="141.567" y1="544" x2="4.56689" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="348.191" y1="544" x2="165.567" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="184.017" y1="592" x2="4.56689" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="312.848" y1="592" x2="208.017" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="386.574" y1="592" x2="336.848" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="448.401" y1="592" x2="410.574" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="126.633" y1="616" x2="4.56689" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="401.133" y1="616" x2="150.633" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="616" x2="425.133" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="179.457" y1="640" x2="4.56689" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="329.569" y1="640" x2="203.457" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="141.567" y1="4" x2="4.56689" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="416.067" y1="4" x2="165.567" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="4" x2="440.067" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="194.567" y1="28" x2="4.56689" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="324.067" y1="28" x2="218.567" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="473.567" y1="28" x2="348.067" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="231.901" y1="52" x2="4.56689" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="291.186" y1="52" x2="255.901" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="442.058" y1="52" x2="315.186" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="52" x2="466.058" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="141.567" y1="76" x2="4.56689" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="348.191" y1="76" x2="165.567" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="184.017" y1="124" x2="4.56689" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="312.848" y1="124" x2="208.017" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="386.574" y1="124" x2="336.848" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="448.401" y1="124" x2="410.574" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="126.633" y1="148" x2="4.56689" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="401.133" y1="148" x2="150.633" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="526.567" y1="148" x2="425.133" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="179.457" y1="172" x2="4.56689" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="329.569" y1="172" x2="203.457" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<rect x="563" y="208" width="156" height="256" rx="16" fill="#A4A7B0" opacity="0.05"/>
<g opacity="0.2">
<line x1="682.214" y1="228" x2="583" y2="228" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="638.472" y1="252" x2="583" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="681.396" y1="252" x2="654.472" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="617.989" y1="276" x2="583" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="276" x2="633.989" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="603.168" y1="300" x2="583" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="657.873" y1="300" x2="619.168" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="637" y1="324" x2="583" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="688.836" y1="324" x2="653" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="657.474" y1="372" x2="583" y2="372" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="633" y1="396" x2="583" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="396" x2="649" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="585.416" y1="420" x2="583" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="621.731" y1="420" x2="601.416" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="699" y1="420" x2="637.731" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
<g opacity="0.2">
<line x1="619.471" y1="444" x2="583" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>
</body>
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script> <script 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> </html>
`, `,
{ {
@@ -21,6 +21,7 @@ export type AIChatMessage = {
role: AIMessageRole; role: AIMessageRole;
content: React.ReactNode; content: React.ReactNode;
query?: string; query?: string;
note?: string;
}; };
export type AIChatPendingTool = { export type AIChatPendingTool = {
@@ -391,11 +392,11 @@ export function AIChatProvider(props: {
return { return {
...state, ...state,
messages: [ messages: [
...state.messages,
{ {
role: AIMessageRole.User, role: AIMessageRole.User,
content: input.message, content: input.message,
query: input.message, query: input.message,
note: state.messages.length > 0 ? 'Starting a new chat' : undefined,
}, },
], ],
query: input.message, query: input.message,
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useLanguage } from '@/intl/client'; import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate'; import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { Assistant } from '../AI'; import type { Assistant } from '../AI';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
@@ -23,7 +24,7 @@ export function AIChatButton(props: {
iconOnly={!showLabel} iconOnly={!showLabel}
size="medium" size="medium"
variant="header" variant="header"
className="h-9 px-2.5 max-md:[&_.button-content]:hidden" className={tcls('h-9 px-2.5')}
label={ label={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)} {t(language, 'ai_chat_ask', assistant.label)}
@@ -37,7 +38,7 @@ export function AIChatButton(props: {
} }
onClick={() => assistant.open()} onClick={() => assistant.open()}
> >
{showLabel ? t(language, 'ask') : null} {showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
</Button> </Button>
); );
} }
@@ -140,7 +140,7 @@ export function AIChatInput(props: {
<span className="leading-none"> <span className="leading-none">
{t(language, 'ai_chat_context_title')} {t(language, 'ai_chat_context_title')}
</span> </span>
<Icon icon="question-circle" className="size-3 shrink-0" /> <Icon icon="question-circle" className="size-3" />
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
</HoverCardRoot> </HoverCardRoot>
@@ -6,14 +6,13 @@ import type React from 'react';
import type { AIChatController, AIChatState } from '../AI'; import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations'; import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback'; import { AIResponseFeedback } from './AIResponseFeedback';
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: { export function AIChatMessages(props: {
chat: AIChatState; chat: AIChatState;
chatController: AIChatController; chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>; lastUserMessageRef?: React.RefObject<HTMLDivElement>;
}) { }) {
const { chat, chatController, lastUserMessageRef } = props; const { chat, lastUserMessageRef } = props;
return ( return (
<> <>
@@ -24,57 +23,63 @@ export function AIChatMessages(props: {
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User); index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
return ( return (
<div <>
ref={isLastUserMessage ? lastUserMessageRef : undefined} {message.note ? (
data-testid="ai-chat-message" <div
className={tcls( key={`${index}-note`}
message.content ? 'animate-fade-in-slow' : '', className="self-center text-tint-subtle text-xs"
'shrink-0', >
'last:min-h-[calc(100%-5rem)]', {message.note}
'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}
{isLastMessage && chat.loading ? (
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!message.content ? <HoldMessage /> : null}
<LoadingSkeleton />
</div> </div>
) : null} ) : null}
<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}
{isLastMessage ? ( {isLastMessage && chat.loading ? (
<> <div className="flex w-full animate-fade-in-slow flex-col gap-2">
{!chat.loading && {!message.content ? <HoldMessage /> : null}
!chat.error && <LoadingSkeleton />
chat.query && </div>
chat.responseId && ) : null}
chat.pendingTools.length === 0 ? (
<AIResponseFeedback {isLastMessage ? (
responseId={chat.responseId} <>
query={chat.query} {!chat.loading &&
className="-ml-1 -mt-4" !chat.error &&
/> chat.query &&
) : null} chat.responseId &&
<AIChatToolConfirmations chat={chat} /> chat.pendingTools.length === 0 ? (
<AIChatFollowupSuggestions <AIResponseFeedback
chat={chat} responseId={chat.responseId}
chatController={chatController} query={chat.query}
/> className="-ml-1 -mt-4"
</> />
) : null} ) : null}
</div> <AIChatToolConfirmations chat={chat} />
</>
) : null}
</div>
</>
); );
})} })}
</> </>
@@ -4,14 +4,19 @@ import type { GitBookSiteContext } from '@/lib/context';
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi'; import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
import * as React from 'react'; import * as React from 'react';
import { createContext, useContext } 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. * In-memory cache of visitor claim readers keyed by contextId.
*/ */
const adaptiveVisitorReaderCache = new Map< const adaptiveVisitorReaderCache = new Map<
string, string,
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>> ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
>(); >();
function createResourceReader<T>(promise: Promise<T>) { function createResourceReader<T>(promise: Promise<T>) {
@@ -47,7 +52,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
if (!res.ok) { if (!res.ok) {
return null; return null;
} }
return await res.json<AdaptiveVisitorClaims>(); return await res.json<AdaptiveVisitorClaimsData>();
} catch { } catch {
return null; return null;
} }
@@ -59,7 +64,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
return reader; return reader;
} }
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null; export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null); const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
@@ -1,3 +1 @@
export * from './types';
export * from './utils';
export * from './AdaptiveVisitorContextProvider'; 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 ?? {},
},
}
: {}),
};
}
@@ -5,7 +5,6 @@ import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights'; import { useVisitorSession } from '../Insights';
import { useCurrentPagePath } from '../hooks'; import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives'; import { DateRelative } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper'; import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton'; import { RefreshContentButton } from './RefreshContentButton';
import { import {
@@ -18,108 +17,48 @@ import {
ToolbarSubtitle, ToolbarSubtitle,
ToolbarTitle, ToolbarTitle,
} from './Toolbar'; } from './Toolbar';
import { import type { AdminToolbarClientProps } from './types';
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
export function AdminToolbarClient(props: AdminToolbarClientProps) { export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props; const { context } = props;
const {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
} = useToolbarVisibility({
onPersistentClose,
onSessionClose,
onToggleMinify,
});
const visitorSession = useVisitorSession(); 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 there is a change request, show the change request toolbar
if (context.changeRequest) { if (context.changeRequest) {
return ( return (
<ToolbarControlsWrapper value={toolbarControls}> <IframeWrapper>
<ChangeRequestToolbar <MotionConfig reducedMotion="user">
context={context} <ChangeRequestToolbar context={context} />
minified={minified} </MotionConfig>
onMinifiedChange={setMinified} </IframeWrapper>
/>
</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 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) { if (context.revisionId !== context.space.revision) {
return ( return (
<ToolbarControlsWrapper value={toolbarControls}> <IframeWrapper>
<RevisionToolbar <MotionConfig reducedMotion="user">
context={context} <RevisionToolbar context={context} />
minified={minified} </MotionConfig>
onMinifiedChange={setMinified} </IframeWrapper>
/>
</ToolbarControlsWrapper>
); );
} }
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar // If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitorSession?.organizationId === context.organizationId) { if (visitorSession?.organizationId === context.organizationId) {
return ( return (
<ToolbarControlsWrapper value={toolbarControls}> <IframeWrapper>
<AuthenticatedUserToolbar <MotionConfig reducedMotion="user">
context={context} <AuthenticatedUserToolbar context={context} />
minified={minified} </MotionConfig>
onMinifiedChange={setMinified} </IframeWrapper>
/>
</ToolbarControlsWrapper>
); );
} }
return null;
} }
/** function ChangeRequestToolbar(props: AdminToolbarClientProps) {
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views. const { context } = props;
*/
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; const { changeRequest, site } = context;
if (!changeRequest) { if (!changeRequest) {
throw new Error('Change request is not set'); throw new Error('Change request is not set');
@@ -132,11 +71,11 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
}); });
return ( return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview"> <Toolbar>
<ToolbarBody> <ToolbarBody>
<ToolbarTitle <ToolbarTitle
prefix={`Change #${changeRequest.number}:`} prefix="Change request"
suffix={`${changeRequest.subject || 'Untitled'}`} suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
/> />
<ToolbarSubtitle <ToolbarSubtitle
subtitle={ subtitle={
@@ -149,13 +88,9 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSeparator /> <ToolbarSeparator />
<ToolbarActions> <ToolbarButtonGroup>
{/* Refresh to retrieve latest changes */} {/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null} {updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */} {/* Comment in app */}
<ToolbarButton <ToolbarButton
title="Comment in a GitBook" title="Comment in a GitBook"
@@ -190,13 +125,16 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
})} })}
icon="code-pull-request" icon="code-pull-request"
/> />
</ToolbarActions>
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
</ToolbarButtonGroup>
</Toolbar> </Toolbar>
); );
} }
function RevisionToolbar(props: ToolbarViewProps) { function RevisionToolbar(props: AdminToolbarClientProps) {
const { context, minified, onMinifiedChange } = props; const { context } = props;
const { revision, site } = context; const { revision, site } = context;
if (!revision) { if (!revision) {
throw new Error('Revision is not set'); throw new Error('Revision is not set');
@@ -207,7 +145,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab'; const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return ( return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview"> <Toolbar>
<ToolbarBody> <ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} /> <ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle <ToolbarSubtitle
@@ -219,7 +157,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
/> />
</ToolbarBody> </ToolbarBody>
<ToolbarSeparator /> <ToolbarSeparator />
<ToolbarActions> <ToolbarButtonGroup>
{/* Open commit in Git client */} {/* Open commit in Git client */}
<ToolbarButton <ToolbarButton
title={ title={
@@ -267,26 +205,22 @@ function RevisionToolbar(props: ToolbarViewProps) {
})} })}
icon="code-commit" icon="code-commit"
/> />
</ToolbarActions> </ToolbarButtonGroup>
</Toolbar> </Toolbar>
); );
} }
function AuthenticatedUserToolbar(props: ToolbarViewProps) { function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
const { context, minified, onMinifiedChange } = props; const { context } = props;
const { revision, space, site } = context; const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({ const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision, revisionId: space.revision,
}); });
return ( return (
<Toolbar <Toolbar>
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<ToolbarBody> <ToolbarBody>
<ToolbarTitle suffix={context.site.title} /> <ToolbarTitle prefix="Site" suffix={context.site.title} />
<ToolbarSubtitle <ToolbarSubtitle
subtitle={ subtitle={
<> <>
@@ -296,14 +230,9 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
/> />
</ToolbarBody> </ToolbarBody>
<ToolbarSeparator /> <ToolbarSeparator />
<ToolbarActions> <ToolbarButtonGroup>
{/* Refresh to retrieve latest changes */} {/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null} {updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton <ToolbarButton
title="Open site in GitBook" title="Open site in GitBook"
href={getToolbarHref({ href={getToolbarHref({
@@ -311,10 +240,8 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
siteId: site.id, siteId: site.id,
buttonId: 'site', buttonId: 'site',
})} })}
icon="gears" icon="gear"
/> />
{/* Customize in GitBook */}
<ToolbarButton <ToolbarButton
title="Customize in GitBook" title="Customize in GitBook"
href={getToolbarHref({ href={getToolbarHref({
@@ -324,8 +251,6 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
})} })}
icon="palette" icon="palette"
/> />
{/* Open insights in GitBook */}
<ToolbarButton <ToolbarButton
title="Open insights in GitBook" title="Open insights in GitBook"
href={getToolbarHref({ href={getToolbarHref({
@@ -335,22 +260,12 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
})} })}
icon="chart-simple" icon="chart-simple"
/> />
</ToolbarActions> <EditPageButton href={space.urls.app} siteId={site.id} />
</ToolbarButtonGroup>
</Toolbar> </Toolbar>
); );
} }
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: { function EditPageButton(props: {
href: string; href: string;
siteId: string; siteId: string;
@@ -3,23 +3,18 @@
--seg-A-color: #2782c4; --seg-A-color: #2782c4;
--seg-B-color: #43b7f2; --seg-B-color: #43b7f2;
--seg-C-color: #8be2ff; --seg-C-color: #8be2ff;
--trace-color: #46474c;
--T: 2s; --T: 2s;
shape-rendering: geometricPrecision; shape-rendering: geometricPrecision;
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
} }
.static .trace { :global(html.dark) .svgLogo {
animation: none; --logo-fill: var(--color-neutral-800);
fill: var(--logo-fill); --seg-A-color: #19537d;
stroke: none; --seg-B-color: #2782c4;
} --seg-C-color: #43b7f2;
--trace-color: #46474c;
.static .seg {
animation: none;
opacity: 0;
} }
/* Base segment animation */ /* Base segment animation */
@@ -182,4 +177,4 @@
fill: var(--logo-fill); fill: var(--logo-fill);
stroke:none; stroke:none;
} }
} }
@@ -2,16 +2,10 @@ import { tcls } from '@/lib/tailwind';
import type React from 'react'; import type React from 'react';
import styles from './AnimatedLogo.module.css'; import styles from './AnimatedLogo.module.css';
interface AnimatedLogoProps { export const AnimatedLogo: React.FC = () => {
shouldAnimate?: boolean;
}
export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
const { shouldAnimate = true } = props;
return ( return (
<svg <svg
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)} className={styles.svgLogo}
width="28" width="28"
height="28" height="28"
viewBox="0 0 28 28" viewBox="0 0 28 28"
@@ -1,232 +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);
};
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
React.useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
const items = [
controls?.minimize
? {
id: 'minimize',
icon: 'minus',
label: 'Minimize',
onClick: controls.minimize,
}
: null,
controls?.closeSession
? {
id: 'session-close',
icon: 'xmark',
label: 'Close for one session',
onClick: controls.closeSession,
}
: null,
controls?.closePersistent
? {
id: 'persistent-close',
icon: 'ban',
label: "Don't show again",
onClick: controls.closePersistent,
}
: null,
].filter(Boolean) as Array<ArcMenuItem>;
const sharedMotionStyle = motionValues
? {
x: motionValues.x,
}
: undefined;
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
onClick={() => {
setOpen((v) => !v);
}}
motionValues={motionValues}
icon="eye-slash"
>
{/* Expanding arc menu */}
{open && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
>
<div
className={tcls(
'pointer-events-none absolute left-0 overflow-visible',
styles.arcMenuPath
)}
ref={ref}
>
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={items.length - 1 - index}
key={item.icon}
{...item}
onClick={() => {
setOpen(false);
item.onClick?.();
}}
/>
))}
</div>
</motion.div>
)}
</ToolbarButton>
);
}
type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
onClick?: () => void;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
disabled?: boolean;
className?: string;
iconClassName?: string;
};
export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
label,
disabled,
className,
onClick = () => {},
icon,
iconClassName,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = calculateRotation();
return (
<div className="pointer-events-none">
<button
type="button"
onClick={() => {
onClick();
}}
style={
{
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
} as React.CSSProperties
}
className={tcls(
'group',
'absolute',
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</div>
);
}
@@ -1,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,39 +1,21 @@
'use client'; 'use client';
import { import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
AnimatePresence,
type MotionValue,
motion,
useReducedMotion,
useSpring,
} from 'motion/react';
import React from 'react'; import React from 'react';
import { AnimatedLogo } from './AnimatedLogo'; import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import { Tooltip } from '../primitives'; import { Tooltip } from '../primitives';
import { getCopyVariants, toolbarEasings } from './transitions'; import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect'; import { useMagnificationEffect } from './useMagnificationEffect';
const DURATION_LOGO_APPEARANCE = 2000; const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100; const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
interface ToolbarProps { export function Toolbar(props: { children: React.ReactNode }) {
label: React.ReactNode; const [minified, setMinified] = React.useState(true);
children: React.ReactNode; const [showToolbarControls, setShowToolbarControls] = React.useState(false);
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 [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 // Wait for page to be ready, then show the toolbar
React.useEffect(() => { React.useEffect(() => {
@@ -49,41 +31,16 @@ export function Toolbar(props: ToolbarProps) {
} }
}, []); }, []);
// After toolbar appears, wait, then show the full content // After toolbar appears, wait then show the full content
React.useEffect(() => { React.useEffect(() => {
if (!isReady || autoExpandTriggeredRef.current) { if (isReady) {
return; const expandAfterTimeout = setTimeout(() => {
setMinified(false);
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
return () => clearTimeout(expandAfterTimeout);
} }
}, [isReady]);
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 // Don't render anything until page is ready
if (!isReady) { if (!isReady) {
@@ -91,47 +48,61 @@ export function Toolbar(props: ToolbarProps) {
} }
return ( return (
<Tooltip label={label}> <motion.div
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"> onMouseEnter={() => setShowToolbarControls(true)}
<AnimatePresence mode="wait"> onMouseLeave={() => setShowToolbarControls(false)}
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
onClick={() => { >
if (minified) { <AnimatePresence mode="wait">
setShouldAnimateLogo(false); <motion.div
onMinifiedChange(false); onClick={() => {
} if (minified) {
}} setMinified((prev) => !prev);
layout }
transition={toolbarEasings.spring} }}
className={tcls( layout
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5', transition={toolbarEasings.spring}
'flex', className={tcls(
'items-center', minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'justify-center', 'flex',
'min-h-11', 'items-center',
'min-w-12', 'justify-center',
'h-12', 'min-h-11',
'py-2', 'min-w-12',
'backdrop-blur-sm', 'h-12',
'origin-center', 'py-2',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8', 'border-tint-1/3',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]', 'backdrop-blur-sm',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]' 'origin-center',
)} 'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]',
style={{ 'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions )}
}} initial={{
> scale: 1,
{/* Logo with stroke segments animation in blue-tints */} opacity: 1,
<motion.div layout> }}
<AnimatedLogo shouldAnimate={shouldAnimateLogo} /> animate={{
</motion.div> scale: 1,
opacity: 1,
{!minified ? children : null} boxShadow: minified
? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)'
: '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)',
}}
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 />
</motion.div> </motion.div>
</AnimatePresence>
</motion.div> {!minified ? props.children : null}
</Tooltip>
{!minified && showToolbarControls && <MinifyButton setMinified={setMinified} />}
</motion.div>
</AnimatePresence>
</motion.div>
); );
} }
@@ -158,15 +129,12 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
className="flex items-center gap-1 overflow-visible pr-2 pl-4" className="flex items-center gap-1 overflow-visible pr-2 pl-4"
> >
{buttonChildren.map((child, index) => { {buttonChildren.map((child, index) => {
const motionValues = buttonMotionValues[index];
const childEl = child as React.ReactElement; const childEl = child as React.ReactElement;
const childKey = childEl.key ?? `toolbar-button-${index}`; return React.cloneElement(childEl, {
return ( key: index,
<ToolbarButtonWrapper motionValues,
key={childKey} });
child={childEl}
rawMotionValues={buttonMotionValues[index]}
/>
);
})} })}
</motion.div> </motion.div>
); );
@@ -180,27 +148,15 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
icon: IconName; icon: IconName;
iconClassName?: string; iconClassName?: string;
title?: React.ReactNode; title?: React.ReactNode;
children?: React.ReactNode;
} }
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => { export function ToolbarButton(props: ToolbarButtonProps) {
const { const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
title, props;
disabled,
motionValues,
className,
style,
href,
onClick,
icon,
iconClassName,
children,
} = props;
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
return ( return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}> <motion.div variants={toolbarEasings.staggeringChild}>
{children ? children : null}
<Tooltip label={title}> <Tooltip label={title}>
<motion.a <motion.a
href={href} href={href}
@@ -233,77 +189,39 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
'gap-1', 'gap-1',
'text-sm', 'text-sm',
'rounded-full', 'rounded-full',
'border-neutral-500',
'outline-neutral-800',
'outline-1',
'border',
'truncate', 'truncate',
'text-tint-1', 'text-tint-1',
'dark:text-tint-12', 'dark:text-tint-12',
'cursor-pointer', 'cursor-pointer',
'transition-colors', 'transition-colors',
'size-8', 'size-8',
'bg-tint-1/3',
'hover:bg-tint-1/4',
'dark:bg-tint-3',
'dark:hover:bg-tint-1',
disabled ? 'cursor-not-allowed opacity-50' : '', disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid', 'shadow-1xs'
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)} )}
> >
<Icon <Icon icon={icon} className={tcls('size-4', iconClassName)} />
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a> </motion.a>
</Tooltip> </Tooltip>
</motion.div> </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,
};
return React.cloneElement(child, {
motionValues,
});
} }
export function ToolbarSeparator() { export function ToolbarSeparator() {
return <div className="h-5 w-px bg-tint-1/3" />; return <div className="h-5 w-px bg-tint-1/3" />;
} }
export function ToolbarTitle(props: { prefix?: string; suffix: string }) { export function ToolbarTitle(props: { prefix: string; suffix: string }) {
return ( return (
<div className="flex items-center gap-1 text-xs "> <div className="flex items-center gap-1 text-xs ">
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null} <ToolbarTitlePrefix title={props.prefix} />
<ToolbarTitleSuffix title={props.suffix} /> <ToolbarTitleSuffix title={props.suffix} />
</div> </div>
); );
@@ -313,7 +231,7 @@ function ToolbarTitlePrefix(props: { title: string }) {
return ( return (
<motion.span <motion.span
{...getCopyVariants(0)} {...getCopyVariants(0)}
className="truncate font-medium text-neutral-1 dark:text-neutral-12" className="font-light text-neutral-7 dark:text-neutral-3"
> >
{props.title} {props.title}
</motion.span> </motion.span>
@@ -324,7 +242,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
return ( return (
<motion.span <motion.span
{...getCopyVariants(1)} {...getCopyVariants(1)}
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12" className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
> >
{props.title} {props.title}
</motion.span> </motion.span>
@@ -335,9 +253,36 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return ( return (
<motion.span <motion.span
{...getCopyVariants(1)} {...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80" className="text-neutral-7 text-xxs dark:text-neutral-2"
> >
{props.subtitle} {props.subtitle}
</motion.span> </motion.span>
); );
} }
function MinifyButton(props: { setMinified: (minified: boolean) => void }) {
return (
<Tooltip label="Minify">
<motion.div
{...minifyButtonAnimation}
transition={{
duration: 0.2,
}}
whileHover={{
scale: 1.05,
}}
onClick={(e) => {
e.stopPropagation();
props.setMinified(true);
}}
className={tcls(
'-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border',
'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600',
'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100'
)}
>
<Icon icon="minus" className="size-2 text-neutral-1 dark:text-neutral-9" />
</motion.div>
</Tooltip>
);
}
@@ -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);
}
@@ -3,4 +3,3 @@ export * from './AdminToolbarClient';
export * from './IframeWrapper'; export * from './IframeWrapper';
export * from './Toolbar'; export * from './Toolbar';
export * from './transitions'; export * from './transitions';
export * from './utils';
@@ -54,7 +54,4 @@ export type AdminToolbarContext = {
export interface AdminToolbarClientProps { export interface AdminToolbarClientProps {
context: AdminToolbarContext; context: AdminToolbarContext;
onSessionClose?: () => void;
onPersistentClose?: () => void;
onToggleMinify?: () => void;
} }
@@ -1,6 +1,11 @@
import { type MotionValue, motionValue } from 'motion/react'; import { type MotionValue, motionValue } from 'framer-motion';
import React from 'react'; import React from 'react';
interface ButtonMotionValues {
scale: MotionValue<number>;
x: MotionValue<number>;
}
interface MagnificationConfig { interface MagnificationConfig {
/** Size of each button in pixels - used for spacing calculations */ /** Size of each button in pixels - used for spacing calculations */
buttonSize?: number; buttonSize?: number;
@@ -18,16 +23,21 @@ interface MagnificationConfig {
const defaultConfig: Required<MagnificationConfig> = { const defaultConfig: Required<MagnificationConfig> = {
buttonSize: 32, // Standard button size for 32px (size-8) buttons buttonSize: 32, // Standard button size for 32px (size-8) buttons
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming
influenceRadius: 80, // ~2.5 button widths of influence influenceRadius: 80, // ~2.5 button widths of influence
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
padding: 10, // Small buffer zone around container edges padding: 10, // Small buffer zone around container edges
}; };
const resetMotionValues = ( // Helper functions for cleaner code
motionValues: Array<{ scale: MotionValue<number>; x: MotionValue<number> }> const createMotionValues = (count: number): ButtonMotionValues[] =>
) => { Array.from({ length: count }, () => ({
scale: motionValue(1),
x: motionValue(0),
}));
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
motionValues.forEach(({ scale, x }) => { motionValues.forEach(({ scale, x }) => {
scale.set(1); scale.set(1);
x.set(0); x.set(0);
@@ -36,17 +46,10 @@ const resetMotionValues = (
const captureButtonPositions = (buttons: HTMLElement[]) => { const captureButtonPositions = (buttons: HTMLElement[]) => {
// Reset transforms to get original positions // Reset transforms to get original positions
buttons.forEach((button) => { buttons.forEach((button) => (button.style.transform = ''));
button.style.transform = '';
});
return buttons.map((button) => { return buttons.map((button) => {
const rect = button.getBoundingClientRect(); const rect = button.getBoundingClientRect();
return { return { left: rect.left, width: rect.width };
left: rect.left,
width: rect.width,
top: rect.top,
height: rect.height,
};
}); });
}; };
@@ -56,46 +59,26 @@ const calculateScale = (
containerRect: DOMRect, containerRect: DOMRect,
config: Required<MagnificationConfig> config: Required<MagnificationConfig>
) => { ) => {
// Calculate only X-axis distance from mouse to button center
const distance = Math.abs(mouseX - buttonCenterX); const distance = Math.abs(mouseX - buttonCenterX);
if (distance > config.influenceRadius) return 1; if (distance > config.influenceRadius) return 1;
// Calculate distance from container edge (X-axis only) // Edge influence to reduce scaling near container edges
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX); const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
const distanceFromEdge = distanceFromEdgeX; const edgeInfluence = Math.min(distanceFromEdge / 20, 1);
// Define the "heart" zone - 8px from center (16x16px total area) // Exponential scaling curve
const heartZoneRadius = 8; const progress = distance / config.influenceRadius;
const exponentialProgress = (1 - progress) ** config.scaleExponent;
const baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
let baseScale: number; return 1 + (baseScale - 1) * edgeInfluence;
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 = ( const calculateSpacing = (
buttonIndex: number, buttonIndex: number,
buttonEffects: Array<{ scale: number; translateX: number }>, buttonEffects: Array<{ scale: number; translateX: number }>,
positions: Array<{ left: number; width: number; top: number; height: number }>, positions: Array<{ left: number; width: number }>,
config: Required<MagnificationConfig> config: Required<MagnificationConfig>
) => { ) => {
const currentPos = positions[buttonIndex]; const currentPos = positions[buttonIndex];
@@ -140,19 +123,10 @@ export function useMagnificationEffect(props: {
config?: MagnificationConfig; config?: MagnificationConfig;
}) { }) {
const { childrenCount, containerRef, config } = props; const { childrenCount, containerRef, config } = props;
const originalPositionsRef = React.useRef< const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
Array<{ left: number; width: number; top: number; height: number }> const originalPositionsRef = React.useRef<Array<{ left: number; width: number }>>([]);
>([]);
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]); const finalConfig = { ...defaultConfig, ...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(() => { React.useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -167,6 +141,11 @@ export function useMagnificationEffect(props: {
return; return;
} }
// Initialize motion values if button count changed
if (buttonMotionValues.length !== buttons.length) {
setButtonMotionValues(createMotionValues(buttons.length));
}
const handleMouseMove = (event: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
const buttons = Array.from( const buttons = Array.from(
container.querySelectorAll('.toolbar-button') container.querySelectorAll('.toolbar-button')
@@ -231,7 +210,17 @@ export function useMagnificationEffect(props: {
container.removeEventListener('mousemove', handleMouseMove); container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('mouseleave', handleMouseLeave);
}; };
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]); }, [
finalConfig.buttonSize,
finalConfig.maxScale,
finalConfig.influenceRadius,
finalConfig.spacingMultiplier,
finalConfig.scaleExponent,
finalConfig.padding,
containerRef,
buttonMotionValues,
childrenCount,
]);
return { buttonMotionValues }; 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,
};
}
@@ -89,7 +89,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
node.data && 'fullWidth' in node.data && node.data.fullWidth node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl' ? 'max-w-screen-xl'
: 'max-w-3xl', : 'max-w-3xl',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full', FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
blockStyle, blockStyle,
]} ]}
isEstimatedOffscreen={isOffscreen} isEstimatedOffscreen={isOffscreen}
@@ -24,7 +24,6 @@ export function Caption(
wrapperStyle?: ClassValue; wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile; block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean; withBorder?: boolean;
withFrame?: boolean;
} & DocumentContextProps } & DocumentContextProps
) { ) {
const { const {
@@ -34,7 +33,6 @@ export function Caption(
context, context,
fit = false, fit = false,
withBorder = false, withBorder = false,
withFrame = false,
wrapperStyle = [ wrapperStyle = [
'relative', 'relative',
'overflow-hidden', 'overflow-hidden',
@@ -46,7 +44,6 @@ export function Caption(
withBorder withBorder
? 'rounded-corners:rounded-sm circular-corners:rounded-2xl after:border-tint-subtle after:border after:rounded circular-corners:after:rounded-2xl rounded-corners:after:rounded-sm dark:after:mix-blend-plus-lighter after:pointer-events-none' ? 'rounded-corners:rounded-sm circular-corners:rounded-2xl after:border-tint-subtle after:border after:rounded circular-corners:after:rounded-2xl rounded-corners:after:rounded-sm dark:after:mix-blend-plus-lighter after:pointer-events-none'
: null, : null,
withFrame && 'p-2',
], ],
style, style,
} = props; } = props;
@@ -65,14 +62,7 @@ export function Caption(
return ( return (
<picture className={tcls('relative', style)}> <picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div> <div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<figcaption <figcaption className={tcls('text-sm', 'text-center', 'mt-2', 'text-tint')}>
className={tcls(
'text-xs',
'text-center',
'text-tint',
withFrame ? 'mt-0.5 mb-2.5' : 'mt-2'
)}
>
<Inlines <Inlines
nodes={captionParagraph.nodes} nodes={captionParagraph.nodes}
document={document} document={document}
@@ -3,19 +3,16 @@
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener'; import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener'; import { useScrollListener } from '@/components/hooks/useScrollListener';
import { useDebounceCallback } from 'usehooks-ts'; import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import { CodeBlockRenderer } from './CodeBlockRenderer'; import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight'; import type { HighlightLine, RenderedInline } from './highlight';
import { plainHighlight } from './plain-highlight'; import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[]; inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables;
}; };
/** /**
@@ -23,21 +20,11 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting. * It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/ */
export function ClientCodeBlock(props: ClientBlockProps) { export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines, inlineExprVariables } = props; const { block, style, inlines } = props;
const blockRef = useRef<HTMLDivElement>(null); const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false); const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables: inlineExprVariables,
});
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
);
const [lines, setLines] = useState<null | HighlightLine[]>(null); const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false); const [highlighting, setHighlighting] = useState(false);
@@ -93,7 +80,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setHighlighting(true); setHighlighting(true);
import('./highlight').then(({ highlight }) => { import('./highlight').then(({ highlight }) => {
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => { highlight(block, inlines).then((lines) => {
if (cancelled) { if (cancelled) {
return; return;
} }
@@ -111,7 +98,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
// Otherwise if the block is not in viewport, we reset to the plain lines // Otherwise if the block is not in viewport, we reset to the plain lines
setLines(null); setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]); }, [isInViewport, block, inlines]);
return ( return (
<CodeBlockRenderer <CodeBlockRenderer
@@ -1,5 +1,3 @@
import * as React from 'react';
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document'; import { getNodeFragmentByType } from '@/lib/document';
@@ -16,64 +14,32 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) { export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = props; const { block, document, style, isEstimatedOffscreen, context } = props;
const inlines = getInlines(block); const inlines = getInlines(block);
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
let hasInlineExpression = false; const body = (() => {
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
const richInlines: RenderedInline[] = inlines if (!fragment) {
// Exclude inline expressions from rendered inline as they are rendered as code text once evaluated return null;
// and so need to be treated as plain code tokens.
.filter((inline) => {
if (inline.inline.type === 'expression') {
hasInlineExpression = true;
return false;
} }
return true; return (
}) <Blocks
.map((inline, index) => { key={index}
const body = (() => { document={document}
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body'); ancestorBlocks={[]}
if (!fragment) { context={context}
return null; nodes={fragment.nodes}
} style="space-y-4"
return ( />
<Blocks );
key={index} })();
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style="space-y-4"
/>
);
})();
return { inline, body }; return { inline, body };
}); });
if (!isEstimatedOffscreen && !hasInlineExpression) { if (!isEstimatedOffscreen) {
// In v2, we render the code block server-side // In v2, we render the code block server-side
const lines = await highlight(block, richInlines); const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />; return <CodeBlockRenderer block={block} style={style} lines={lines} />;
} }
const variables = context.contentContext return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
? {
space: context.contentContext?.revision.variables,
page:
'page' in context.contentContext
? context.contentContext.page.variables
: undefined,
}
: {};
return (
<React.Suspense fallback={null}>
<ClientCodeBlock
block={block}
style={style}
inlines={richInlines}
inlineExprVariables={variables}
/>
</React.Suspense>
);
} }
@@ -32,7 +32,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
<div <div
ref={ref} ref={ref}
aria-busy={ariaBusy} aria-busy={ariaBusy}
className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)} className={tcls('group/codeblock grid grid-flow-col', style)}
> >
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]"> <div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? ( {title ? (
@@ -1,8 +1,7 @@
import type { JSONDocument } from '@gitbook/api'; import type { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { useId } from 'react'; import { useId } from 'react';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { convertCodeStringToBlock } from './utils';
/** /**
* Plain code block with syntax highlighting. * Plain code block with syntax highlighting.
@@ -12,7 +11,31 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props; const { code, syntax } = props;
const id = useId(); const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax }); const block: DocumentBlockCode = {
key: id,
object: 'block',
type: 'code',
data: {
syntax,
},
nodes: code.split('\n').map((line) => ({
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [
{
object: 'leaf',
text: line,
marks: [],
},
],
},
],
})),
};
const document: JSONDocument = { const document: JSONDocument = {
object: 'document', object: 'document',
@@ -64,10 +64,7 @@ export async function preloadHighlight(block: DocumentBlockCode) {
*/ */
export async function highlight( export async function highlight(
block: DocumentBlockCode, block: DocumentBlockCode,
inlines: RenderedInline[], inlines: RenderedInline[]
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): Promise<HighlightLine[]> { ): Promise<HighlightLine[]> {
const langName = getBlockLang(block); const langName = getBlockLang(block);
@@ -77,10 +74,10 @@ export async function highlight(
// - TEMP : language is PowerShell or C++ and browser is Safari: // - TEMP : language is PowerShell or C++ and browser is Safari:
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isnt writable // RegExp#[Symbol.search] throws TypeError when `lastIndex` isnt writable
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772 // Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
return plainHighlight(block, inlines, options); return plainHighlight(block, inlines);
} }
const code = getPlainCodeBlock(block, undefined, options); const code = getPlainCodeBlock(block);
const highlighter = await getSingletonHighlighter({ const highlighter = await getSingletonHighlighter({
langs: [langName], langs: [langName],
@@ -258,17 +255,11 @@ function matchTokenAndInlines(
return result; return result;
} }
function getPlainCodeBlock( function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
code: DocumentBlockCode,
inlines?: InlineIndexed[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): string {
let content = ''; let content = '';
code.nodes.forEach((node, index) => { code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, content.length, inlines, options); const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
content += lineContent; content += lineContent;
if (index < code.nodes.length - 1) { if (index < code.nodes.length - 1) {
@@ -282,10 +273,7 @@ function getPlainCodeBlock(
function getPlainCodeBlockLine( function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation, parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
index: number, index: number,
inlines?: InlineIndexed[], inlines?: InlineIndexed[]
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): string { ): string {
let content = ''; let content = '';
@@ -296,12 +284,7 @@ function getPlainCodeBlockLine(
switch (node.type) { switch (node.type) {
case 'annotation': { case 'annotation': {
const start = index + content.length; const start = index + content.length;
content += getPlainCodeBlockLine( content += getPlainCodeBlockLine(node, index + content.length, inlines);
node,
index + content.length,
inlines,
options
);
const end = index + content.length; const end = index + content.length;
if (inlines) { if (inlines) {
@@ -314,19 +297,6 @@ function getPlainCodeBlockLine(
break; break;
} }
case 'expression': { case 'expression': {
const start = index + content.length;
const exprValue =
options?.evaluateInlineExpression?.(node.data.expression) ?? '';
content += exprValue;
const end = start + exprValue.length;
if (inlines) {
inlines.push({
inline: node,
start,
end,
});
}
break; break;
} }
default: { default: {
@@ -9,13 +9,9 @@ import type { HighlightLine, HighlightToken, RenderedInline } from './highlight'
*/ */
export function plainHighlight( export function plainHighlight(
block: DocumentBlockCode, block: DocumentBlockCode,
inlines: RenderedInline[], inlines: RenderedInline[]
options?: {
evaluateInlineExpression?: (expr: string) => string;
}
): HighlightLine[] { ): HighlightLine[] {
const inlinesCopy = Array.from(inlines); const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => { return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => { const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') { if (node.object === 'text') {
@@ -24,14 +20,6 @@ export function plainHighlight(
content: getNodeText(node), content: getNodeText(node),
}; };
} }
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift(); const inline = inlinesCopy.shift();
return { return {
type: 'annotation', type: 'annotation',
@@ -1,219 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { convertCodeStringToBlock } from './utils';
describe('convertCodeStringToBlock', () => {
it('converts plain code string without placeholders', () => {
const result = convertCodeStringToBlock({
key: 'test1',
code: 'console.log("hello");',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test1',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'console.log("hello");', marks: [] }],
},
],
},
],
});
});
it('converts a single placeholder into an expression node', () => {
const result = convertCodeStringToBlock({
key: 'test2',
code: 'const config = { API_KEY: "$$__X-GITBOOK-PREFILL[visitor.claims.apiKey ?? "YOUR_API_KEY"]__$$" }',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test2',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [
{ object: 'leaf', text: 'const config = { API_KEY: "', marks: [] },
],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'visitor.claims.apiKey ?? "YOUR_API_KEY"' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: '" }', marks: [] }],
},
],
},
],
});
});
it('handles multiple placeholders in one line', () => {
const result = convertCodeStringToBlock({
key: 'test3',
code: 'let a = $$__X-GITBOOK-PREFILL[valA]__$$, b = $$__X-GITBOOK-PREFILL[valB]__$$;',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test3',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'let a = ', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'valA' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: ', b = ', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'valB' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: ';', marks: [] }],
},
],
},
],
});
});
it('handles multiple placeholders across different lines', () => {
const result = convertCodeStringToBlock({
key: 'test4',
code: [
'const name = "$$__X-GITBOOK-PREFILL[visitor.claims.userName]__$$";',
'const age = $$__X-GITBOOK-PREFILL[visitor.claims.userAge]__$$;',
'console.log(name, age);',
].join('\n'),
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test4',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'const name = "', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'visitor.claims.userName' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: '";', marks: [] }],
},
],
},
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'const age = ', marks: [] }],
},
{
object: 'inline',
type: 'expression',
data: { expression: 'visitor.claims.userAge' },
isVoid: true,
},
{
object: 'text',
leaves: [{ object: 'leaf', text: ';', marks: [] }],
},
],
},
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [
{ object: 'leaf', text: 'console.log(name, age);', marks: [] },
],
},
],
},
],
});
});
it('returns an empty code block for empty string', () => {
const result = convertCodeStringToBlock({
key: 'test5',
code: '',
syntax: 'javascript',
});
expect(result).toEqual({
key: 'test5',
object: 'block',
type: 'code',
data: { syntax: 'javascript' },
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [],
},
],
});
});
});
@@ -1,67 +0,0 @@
import type { DocumentBlockCode, DocumentBlockCodeLine } from '@gitbook/api';
const PREFILL_WITH_EXPR_REGEX = /\$\$__X-GITBOOK-PREFILL\[(.+?)\]__\$\$/g;
/**
* Convert a raw code string into a `DocumentBlockCode` object representation.
*
* Any placeholder of the form `$$__X-GITBOOK-PREFILL[<expression>]__$$` inside the code
* string is transformed into a DocumentInlineExpression node with its `data.expression` set to the
* extracted `<expression>`.
*/
export function convertCodeStringToBlock(args: {
key: string;
code: string;
syntax: string;
}): DocumentBlockCode {
const { key, code, syntax } = args;
const lines = code.split('\n').map<DocumentBlockCodeLine>((line) => {
const nodes: DocumentBlockCodeLine['nodes'] = [];
let lastIndex = 0;
for (const match of line.matchAll(PREFILL_WITH_EXPR_REGEX)) {
const [placeholder, expression] = match;
const start = match.index ?? 0;
if (start > lastIndex) {
nodes.push({
object: 'text',
leaves: [{ object: 'leaf', text: line.slice(lastIndex, start), marks: [] }],
});
}
if (expression) {
nodes.push({
object: 'inline',
type: 'expression',
data: { expression },
isVoid: true,
});
}
lastIndex = start + placeholder.length;
}
if (lastIndex < line.length) {
nodes.push({
object: 'text',
leaves: [{ object: 'leaf', text: line.slice(lastIndex), marks: [] }],
});
}
return {
object: 'block',
type: 'code-line',
data: {},
nodes,
};
});
return {
key,
object: 'block',
type: 'code',
data: { syntax },
nodes: lines,
};
}
@@ -1,57 +1,17 @@
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api'; import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api';
import React from 'react';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import { Blocks } from '../Blocks'; import { Blocks } from '../Blocks';
export function Columns(props: BlockProps<DocumentBlockColumns>) { export function Columns(props: BlockProps<DocumentBlockColumns>) {
const { block, style, ancestorBlocks, document, context } = props; const { block, style, ancestorBlocks, document, context } = props;
const columnWidths = React.useMemo(() => {
const widths = block.nodes.map((block) => {
const width = block.data.width;
return width ? getFractionalWidth(width) : 0;
});
const totalWidth = widths.reduce<number>((acc, width) => acc + width, 0);
// If not all columns widths are set, distribute the remaining widths as equally as we can
if (totalWidth < 1.0 && widths.some((width) => width === 0)) {
const unsetWidths = widths.filter((width) => width === 0);
let remainingWidth = 1.0 - totalWidth;
let unsetWidthsLength = unsetWidths.length;
widths.forEach((width, index) => {
if (width === 0) {
const calculatedWidth =
Math.round((remainingWidth / unsetWidthsLength) * COLUMN_DIVISIONS) /
COLUMN_DIVISIONS;
widths[index] = calculatedWidth; // Assign width to empty columns
unsetWidthsLength--;
remainingWidth -= calculatedWidth;
}
});
}
return widths;
}, [block.nodes]);
return ( return (
<div <div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
className={tcls( {block.nodes.map((columnBlock) => {
'grid w-full grid-cols-1 gap-x-8 gap-y-4 md:grid-flow-col md:grid-cols-[repeat(var(--grid-slices),minmax(0,1fr))] md:grid-rows-1',
style
)}
style={{ '--grid-slices': COLUMN_DIVISIONS } as React.CSSProperties}
>
{block.nodes.map((columnBlock, index) => {
const columnWidth = columnWidths[index];
return ( return (
<Column <Column
key={columnBlock.key} key={columnBlock.key}
width={ width={columnBlock.data.width}
columnBlock.data.width ??
(columnWidth
? { value: columnWidth * 100, unit: '%' }
: { value: 0, unit: '%' })
}
verticalAlignment={columnBlock.data.verticalAlignment} verticalAlignment={columnBlock.data.verticalAlignment}
> >
<Blocks <Blocks
@@ -71,11 +31,11 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
export function Column(props: { export function Column(props: {
children?: React.ReactNode; children?: React.ReactNode;
width: Length; width?: Length;
verticalAlignment?: VerticalAlignment; verticalAlignment?: VerticalAlignment;
}) { }) {
const { width, verticalAlignment } = props; const { width, verticalAlignment } = props;
const { className, style } = transformLengthToCSS(width) ?? {}; const { className, style } = transformLengthToCSS(width);
return ( return (
<div <div
className={tcls( className={tcls(
@@ -93,25 +53,41 @@ export function Column(props: {
); );
} }
const COLUMN_DIVISIONS = 12; function transformLengthToCSS(length: Length | undefined) {
if (!length) {
export function transformLengthToCSS(length: Length) { return { className: ['md:w-full'] }; // default to full width if no length is specified
if (typeof length === 'number') {
return; // not implemented yet with non-percentage lengths
} }
if (typeof length === 'number') {
return { style: undefined }; // not implemented yet with non-percentage lengths
}
if (length.unit === '%') { if (length.unit === '%') {
return { return {
className: 'md:flex-shrink-0 md:[grid-column:var(--grid-col)]', className: [
style: { 'md:shrink-0',
'--grid-col': `auto / span ${Math.round(length.value * 0.01 * COLUMN_DIVISIONS)}`, COLUMN_WIDTHS[Math.round(length.value * 0.01 * (COLUMN_WIDTHS.length - 1))],
} as React.CSSProperties, ],
}; };
} }
return { style: undefined }; // not implemented yet with non-percentage lengths
} }
function getFractionalWidth(length: Length) { // Tailwind CSS classes for column widths.
if (typeof length === 'number') { // The index of the array corresponds to the percentage width of the column.
return 0; const COLUMN_WIDTHS = [
} 'md:w-0',
return length.value / 100; 'md:w-1/12',
} 'md:w-2/12',
'md:w-3/12',
'md:w-4/12',
'md:w-5/12',
'md:w-6/12',
'md:w-7/12',
'md:w-8/12',
'md:w-9/12',
'md:w-10/12',
'md:w-11/12',
'md:w-full',
];
@@ -25,11 +25,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id = context.getId ? context.getId(id) : id; id = context.getId ? context.getId(id) : id;
return ( return (
<Details <Details id={id} open={context.mode === 'print'} className={style}>
id={id}
open={context.mode === 'print'}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary <summary
className={tcls( className={tcls(
'cursor-pointer', 'cursor-pointer',
@@ -3,8 +3,6 @@ import { Icon, type IconName } from '@gitbook/icons';
import { type ClassValue, tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations';
import { Block, type BlockProps } from './Block'; import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks'; import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing'; import { getBlockTextStyle } from './spacing';
@@ -20,12 +18,6 @@ export function Hint({
const firstLine = getBlockTextStyle(firstNode); const firstLine = getBlockTextStyle(firstNode);
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type); const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type);
const language = contextProps.context.contentContext
? getSpaceLanguage(contextProps.context.contentContext)
: languages.en;
const label = tString(language, `hint_${block.data.style}`);
return ( return (
<div <div
className={tcls( className={tcls(
@@ -46,8 +38,6 @@ export function Hint({
style style
)} )}
aria-label={label}
role="note"
> >
<div <div
className={tcls( className={tcls(
@@ -11,8 +11,8 @@ import type { DocumentContext } from './DocumentView';
export function Images(props: BlockProps<DocumentBlockImages>) { export function Images(props: BlockProps<DocumentBlockImages>) {
const { document, block, style, context, isEstimatedOffscreen } = props; const { document, block, style, context, isEstimatedOffscreen } = props;
const hasMultipleImages = block.nodes.length > 1; const isMultipleImages = block.nodes.length > 1;
const { align = 'center', withFrame } = block.data; const { align = 'center' } = block.data;
return ( return (
<div <div
@@ -24,15 +24,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
align === 'center' && 'justify-center', align === 'center' && 'justify-center',
align === 'right' && 'justify-end', align === 'right' && 'justify-end',
align === 'left' && 'justify-start', align === 'left' && 'justify-start',
hasMultipleImages && ['grid', 'grid-flow-col'], isMultipleImages && ['grid', 'grid-flow-col']
withFrame && [
'rounded-2xl',
'border',
'border-[rgb(234,235,238)]',
'dark:border-[rgb(45,50,58)]',
'relative',
'overflow-hidden',
]
)} )}
> >
{block.nodes.map((node: any, _i: number) => ( {block.nodes.map((node: any, _i: number) => (
@@ -44,7 +36,6 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
siblings={block.nodes.length} siblings={block.nodes.length}
context={context} context={context}
isEstimatedOffscreen={isEstimatedOffscreen} isEstimatedOffscreen={isEstimatedOffscreen}
withFrame={withFrame}
/> />
))} ))}
</div> </div>
@@ -71,9 +62,8 @@ async function ImageBlock(props: {
context: DocumentContext; context: DocumentContext;
siblings: number; siblings: number;
isEstimatedOffscreen: boolean; isEstimatedOffscreen: boolean;
withFrame?: boolean;
}) { }) {
const { block, context, isEstimatedOffscreen, withFrame } = props; const { block, context, isEstimatedOffscreen } = props;
const [src, darkSrc] = await Promise.all([ const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null, context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
@@ -87,66 +77,33 @@ async function ImageBlock(props: {
} }
return ( return (
<div className={tcls('relative', 'overflow-hidden')}> <Caption {...props} fit>
{/* Frame grid */} <Image
{withFrame && ( alt={block.data.alt ?? ''}
<div sizes={imageBlockSizes}
className={tcls( resize={context.contentContext?.imageResizer}
'absolute', sources={{
'-top-0.5', light: {
'-left-0.5', src: src.href,
'right-px', size: src.file?.dimensions,
'bottom-px', },
'opacity-40', dark: darkSrc
'dark:opacity-[0.1]', ? {
'bg-[length:24px_24px,24px_24px]', src: darkSrc.href,
'bg-[linear-gradient(to_right,_rgb(234,235,238)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(234,235,238)_1px,_transparent_1px)]', size: darkSrc.file?.dimensions,
'dark:bg-[linear-gradient(to_right,_rgb(122,128,139)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(122,128,139)_1px,_transparent_1px)]', }
'bg-repeat' : null,
)} }}
/> priority={isEstimatedOffscreen ? 'lazy' : 'high'}
)} preload
zoom
{/* Shadow overlay */} inlineStyle={{
{withFrame && ( maxWidth: '100%',
<div width: getImageDimension(block.data.width, undefined),
className={tcls( height: getImageDimension(block.data.height, 'auto'),
'pointer-events-none absolute inset-0 rounded-2xl', }}
'shadow-[inset_0_0_10px_10px_rgba(255,255,255,0.9)]', />
'dark:shadow-[inset_0_0_10px_10px_rgb(29,29,29)]' </Caption>
)}
/>
)}
<Caption {...props} fit>
<Image
alt={block.data.alt ?? ''}
sizes={imageBlockSizes}
resize={context.contentContext?.imageResizer}
sources={{
light: {
src: src.href,
size: src.file?.dimensions,
},
dark: darkSrc
? {
src: darkSrc.href,
size: darkSrc.file?.dimensions,
}
: null,
}}
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
preload
zoom
inlineStyle={{
maxWidth: '100%',
width: getImageDimension(block.data.width, undefined),
height: getImageDimension(block.data.height, 'auto'),
}}
style={withFrame ? 'rounded-xl' : undefined}
/>
</Caption>
</div>
); );
} }
@@ -5,7 +5,6 @@ import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView'; import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji'; import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton'; import { InlineButton } from './InlineButton';
import { InlineExpression } from './InlineExpression';
import { InlineIcon } from './InlineIcon'; import { InlineIcon } from './InlineIcon';
import { InlineImage } from './InlineImage'; import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink'; import { InlineLink } from './InlineLink';
@@ -52,7 +51,9 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
case 'icon': case 'icon':
return <InlineIcon {...contextProps} inline={inline} />; return <InlineIcon {...contextProps} inline={inline} />;
case 'expression': case 'expression':
return <InlineExpression {...contextProps} inline={inline} />; // The GitBook API should take care of evaluating expressions.
// We should never need to render them.
return null;
default: default:
return nullIfNever(inline); return nullIfNever(inline);
} }
@@ -1,30 +0,0 @@
import * as React from 'react';
import type { DocumentInlineExpression } from '@gitbook/api';
import type { InlineProps } from '../Inline';
import { InlineExpressionValue } from './InlineExpressionValue';
/**
* Render an inline expression.
*/
export function InlineExpression(props: InlineProps<DocumentInlineExpression>) {
const { context, inline } = props;
const { data } = inline;
const variables = context.contentContext
? {
space: context.contentContext?.revision.variables,
page:
'page' in context.contentContext
? context.contentContext.page.variables
: undefined,
}
: {};
return (
<React.Suspense fallback={null}>
<InlineExpressionValue expression={data.expression} variables={variables} />
</React.Suspense>
);
}
@@ -1,26 +0,0 @@
'use client';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useMemo } from 'react';
import type { InlineExpressionVariables } from './types';
import { useEvaluateInlineExpression } from './useEvaluateInlineExpression';
export function InlineExpressionValue(props: {
expression: string;
variables: InlineExpressionVariables;
}) {
const { expression, variables } = props;
const getAdaptiveVisitorClaims = useAdaptiveVisitor();
const visitorClaims = getAdaptiveVisitorClaims();
const evaluateInlineExpression = useEvaluateInlineExpression({
visitorClaims,
variables,
});
const result = useMemo(
() => evaluateInlineExpression(expression),
[expression, evaluateInlineExpression]
);
return <>{result}</>;
}
@@ -1,3 +0,0 @@
export * from './InlineExpression';
export * from './types';
export * from './useEvaluateInlineExpression';
@@ -1,6 +0,0 @@
import type { Variables } from '@gitbook/api';
export interface InlineExpressionVariables {
space?: Variables;
page?: Variables;
}
@@ -1,42 +0,0 @@
import * as React from 'react';
import {
type AdaptiveVisitorClaims,
createExpressionEvaluationContext,
} from '@/components/Adaptive';
import type { Variables } from '@gitbook/api';
import { ExpressionRuntime, formatExpressionResult } from '@gitbook/expr';
/**
* Hook that returns a callback to evaluate an inline expression with visitor data
* and space/page variables as context.
*/
export function useEvaluateInlineExpression(args: {
visitorClaims: AdaptiveVisitorClaims | null;
variables: {
space?: Variables;
page?: Variables;
};
}) {
const { visitorClaims, variables } = args;
const evaluateInlineExpression = React.useMemo(() => {
const runtime = new ExpressionRuntime();
const evaluationContext = createExpressionEvaluationContext({
visitorClaims,
variables,
});
return (expression: string) => {
try {
return formatExpressionResult(
runtime.evaluate(expression, evaluationContext) ?? ''
);
} catch (err) {
console.error('Failed to evaluate expression:', expression, err);
return `{{${expression}}}`;
}
};
}, [variables, visitorClaims]);
return evaluateInlineExpression;
}
@@ -1,23 +1,10 @@
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api'; import type { DocumentInlineIcon } from '@gitbook/api';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import type { InlineProps } from './Inline'; import type { InlineProps } from './Inline';
import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) { export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props; const { inline } = props;
const icon = inline.data.icon as IconName;
// @ts-expect-error remove this comment once API is updated
const color = inline.data.color
? // @ts-expect-error remove "as DocumentMarkColor['data']['text']" once API is updated
(inline.data.color as DocumentMarkColor['data']['text'])
: undefined;
return ( return <Icon icon={inline.data.icon as IconName} className="inline size-[1em]" />;
<Icon
icon={icon}
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
/>
);
} }
@@ -34,7 +34,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
)} )}
> >
<Image <Image
alt={inline.data.alt ?? inline.data.caption ?? ''} alt={inline.data.caption ?? ''}
sizes={sizes} sizes={sizes}
resize={context.contentContext?.imageResizer} resize={context.contentContext?.imageResizer}
sources={{ sources={{
@@ -143,11 +143,11 @@
} }
.openapi-column-preview { .openapi-column-preview {
@apply flex flex-col flex-1 xl:max-2xl:pt-20 lg:py-6 sticky max-h-[calc(100vh-var(--toc-top-offset))] top-(--toc-top-offset); @apply flex flex-col flex-1;
} }
.openapi-column-preview-body { .openapi-column-preview-body {
@apply flex flex-col shrink overflow-hidden gap-4 print-mode:static; @apply flex flex-col gap-4 sticky top-[calc(var(--toc-top-offset)+5rem)] print-mode:static;
} }
.openapi-column-preview pre { .openapi-column-preview pre {
@@ -192,7 +192,7 @@
.openapi-schema-name { .openapi-schema-name {
/* To make double click on the property name select only the name, /* To make double click on the property name select only the name,
we disable selection on the parent and re-enable it on the children. */ we disable selection on the parent and re-enable it on the children. */
@apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5 items-center; @apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5;
} }
.openapi-schema-name .openapi-deprecated { .openapi-schema-name .openapi-deprecated {
@@ -207,10 +207,6 @@
@apply line-through opacity-9; @apply line-through opacity-9;
} }
.openapi-schema-discriminator {
@apply text-primary-subtle/9 text-[0.813rem] lowercase;
}
.openapi-schema-required { .openapi-schema-required {
@apply text-warning-subtle text-[0.813rem] lowercase; @apply text-warning-subtle text-[0.813rem] lowercase;
} }
@@ -317,11 +313,10 @@
} }
.openapi-securities-oauth-flows { .openapi-securities-oauth-flows {
@apply flex flex-col gap-3; @apply flex flex-col gap-2 divide-y divide-tint-subtle;
} }
.openapi-securities-oauth-content, .openapi-securities-oauth-content {
.openapi-securities-scopes {
@apply prose *:!prose-sm *:text-tint; @apply prose *:!prose-sm *:text-tint;
} }
@@ -329,7 +324,7 @@
@apply text-xs; @apply text-xs;
} }
.openapi-securities-scopes ul { .openapi-securities-oauth-content ul {
@apply !my-0; @apply !my-0;
} }
@@ -515,21 +510,13 @@
.openapi-panel, .openapi-panel,
.openapi-codesample, .openapi-codesample,
.openapi-response-examples { .openapi-response-examples {
@apply border shrink min-h-40 overflow-hidden rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm; @apply border rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
}
.openapi-response-examples-panel {
@apply flex flex-col shrink overflow-hidden;
}
.openapi-codesample-panel {
@apply flex flex-col shrink overflow-hidden;
} }
.openapi-panel pre, .openapi-panel pre,
.openapi-codesample pre, .openapi-codesample pre,
.openapi-response-examples pre { .openapi-response-examples pre {
@apply bg-transparent border-none rounded-none shrink shadow-none; @apply bg-transparent border-none rounded-none shadow-none;
} }
.openapi-panel-heading { .openapi-panel-heading {
@@ -592,7 +579,7 @@ body:has(.openapi-select-popover) {
} }
.openapi-select > button { .openapi-select > button {
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint; @apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong max-w-32 rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
@apply hover:bg-tint-hover transition-all; @apply hover:bg-tint-hover transition-all;
} }
@@ -626,7 +613,7 @@ body:has(.openapi-select-popover) {
} }
.openapi-select-item { .openapi-select-item {
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded !outline-none; @apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 *:truncate text-tint ring-0 border-none rounded !outline-none;
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current; @apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
} }
@@ -651,14 +638,6 @@ body:has(.openapi-select-popover) {
} }
/* Section Components */ /* Section Components */
.openapi-section {
@apply flex flex-col overflow-hidden;
}
.openapi-section-body {
@apply flex flex-col shrink overflow-hidden;
}
.openapi-section-header { .openapi-section-header {
@apply flex flex-row items-center; @apply flex flex-row items-center;
} }
@@ -841,7 +820,7 @@ body:has(.openapi-select-popover) {
} }
.openapi-disclosure-trigger-label { .openapi-disclosure-trigger-label {
@apply absolute right-3 mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs; @apply absolute right-3 px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
} }
.openapi-disclosure-trigger-label span { .openapi-disclosure-trigger-label span {
@@ -95,12 +95,8 @@ export async function RecordCard(
}} }}
sizes={[ sizes={[
{ {
media: '(max-width: 640px)',
width: view.cardSize === 'medium' ? 245 : 376, width: view.cardSize === 'medium' ? 245 : 376,
}, },
{
width: view.cardSize === 'medium' ? 490 : 752,
},
]} ]}
resize={context.contentContext?.imageResizer} resize={context.contentContext?.imageResizer}
className={tcls( className={tcls(
@@ -71,18 +71,16 @@ function processCoverValue(value: DocumentTableImageRecord | string[] | null | u
} }
} }
const imageValue = value as DocumentTableImageRecord | null | undefined;
// Check if it's the new schema with objectFit // Check if it's the new schema with objectFit
if (imageValue && typeof imageValue === 'object' && 'ref' in imageValue) { if (value && typeof value === 'object' && 'ref' in value && 'objectFit' in value) {
return { return {
contentRef: imageValue.ref, contentRef: value.ref,
objectFit: imageValue.objectFit, objectFit: value.objectFit,
}; };
} }
// It's a direct ContentRef // It's a direct ContentRef
return { contentRef: imageValue || null }; return { contentRef: value as ContentRefFile | ContentRefURL };
} }
/** /**
@@ -12,8 +12,7 @@ import type {
} from '@gitbook/api'; } from '@gitbook/api';
import React from 'react'; import React from 'react';
import { tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
import { backgroundColorToStyle, textColorToStyle } from './utils/colors';
export function Text(props: { text: DocumentText }) { export function Text(props: { text: DocumentText }) {
const { text } = props; const { text } = props;
@@ -122,3 +121,51 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
</span> </span>
); );
} }
/**
* @TODO replace by DocumentMarkColor['data']['text'] and DocumentMarkColor['data']['background']
* once the API is updated.
*/
type DocumentMarkColorValue =
| 'default'
| 'green'
| 'blue'
| 'red'
| 'orange'
| 'yellow'
| 'purple'
| '$primary'
| '$info'
| '$success'
| '$warning'
| '$danger';
const textColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
default: [],
blue: ['text-blue-500'],
red: ['text-red-500'],
green: ['text-green-500'],
yellow: ['text-yellow-600'],
purple: ['text-purple-500'],
orange: ['text-orange-500'],
$primary: ['text-primary'],
$info: ['text-info'],
$success: ['text-success'],
$warning: ['text-warning'],
$danger: ['text-danger'],
};
const backgroundColorToStyle: { [color in DocumentMarkColorValue]: ClassValue } = {
default: [],
blue: ['bg-mark-blue'],
red: ['bg-mark-red'],
green: ['bg-mark-green'],
yellow: ['bg-mark-yellow'],
purple: ['bg-mark-purple'],
orange: ['bg-mark-orange'],
$primary: ['bg-primary'],
$info: ['bg-info'],
$success: ['bg-success'],
$warning: ['bg-warning'],
$danger: ['bg-danger'],
};
@@ -1,34 +0,0 @@
import type { ClassValue } from '@/lib/tailwind';
import type { DocumentMarkColor } from '@gitbook/api';
export const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassValue } = {
default: [],
blue: ['text-blue-500'],
red: ['text-red-500'],
green: ['text-green-500'],
yellow: ['text-yellow-600'],
purple: ['text-purple-500'],
orange: ['text-orange-500'],
$primary: ['text-primary'],
$info: ['text-info'],
$success: ['text-success'],
$warning: ['text-warning'],
$danger: ['text-danger'],
};
export const backgroundColorToStyle: {
[color in DocumentMarkColor['data']['background']]: ClassValue;
} = {
default: [],
blue: ['bg-mark-blue'],
red: ['bg-mark-red'],
green: ['bg-mark-green'],
yellow: ['bg-mark-yellow'],
purple: ['bg-mark-purple'],
orange: ['bg-mark-orange'],
$primary: ['bg-primary'],
$info: ['bg-info'],
$success: ['bg-success'],
$warning: ['bg-warning'],
$danger: ['bg-danger'],
};
@@ -36,9 +36,13 @@ async function FooterLink(props: { link: CustomizationContentLink; context: GitB
const { link, context } = props; const { link, context } = props;
const resolved = await resolveContentRef(link.to, context); const resolved = await resolveContentRef(link.to, context);
if (!resolved) {
return null;
}
return ( return (
<Link <Link
href={resolved?.href ?? '#'} href={resolved.href}
className={tcls( className={tcls(
'font-normal', 'font-normal',
'text-tint', 'text-tint',
@@ -42,11 +42,11 @@ export function CurrentContentIcon(
} }
: { : {
light: { light: {
src: linker.toPathInSpace('~gitbook/icon?size=large&theme=light'), src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=light'),
size: { width: 256, height: 256 }, size: { width: 256, height: 256 },
}, },
dark: { dark: {
src: linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'), src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=dark'),
size: { width: 256, height: 256 }, size: { width: 256, height: 256 },
}, },
} }
@@ -23,12 +23,6 @@ export function Header(props: {
const { context, withTopHeader, withVariants } = props; const { context, withTopHeader, withVariants } = props;
const { siteSpace, siteSpaces, sections, customization } = context; const { siteSpace, siteSpaces, sections, customization } = context;
const withSections = Boolean(
sections &&
(sections.list.length > 1 || // Show section tabs if there are at least 2 sections or at least 1 section group
sections.list.some((s) => s.object === 'site-section-group'))
);
return ( return (
<header <header
id="site-header" id="site-header"
@@ -63,7 +57,7 @@ export function Header(props: {
'theme-bold:shadow-tint-12/2' 'theme-bold:shadow-tint-12/2'
)} )}
> >
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<div <div
className={tcls( className={tcls(
'gap-4', 'gap-4',
@@ -130,7 +124,10 @@ export function Header(props: {
withSiteVariants={ withSiteVariants={
sections?.list.some( sections?.list.some(
(s) => (s) =>
s.object === 'site-section' && s.siteSpaces.length > 1 s.object === 'site-section' &&
s.siteSpaces.filter(
(s) => s.space.language === siteSpace.space.language
).length > 1
) ?? false ) ?? false
} }
withSections={!!sections} withSections={!!sections}
@@ -150,7 +147,7 @@ export function Header(props: {
</div> </div>
{customization.header.links.length > 0 || {customization.header.links.length > 0 ||
(!withSections && withVariants === 'translations') ? ( (!sections && withVariants === 'translations') ? (
<HeaderLinks> <HeaderLinks>
{customization.header.links.length > 0 ? ( {customization.header.links.length > 0 ? (
<> <>
@@ -170,7 +167,7 @@ export function Header(props: {
/> />
</> </>
) : null} ) : null}
{!withSections && withVariants === 'translations' ? ( {!sections && withVariants === 'translations' ? (
<TranslationsDropdown <TranslationsDropdown
context={context} context={context}
siteSpace={siteSpace} siteSpace={siteSpace}
@@ -184,19 +181,43 @@ export function Header(props: {
</div> </div>
</div> </div>
{sections && withSections ? ( {sections ? (
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96"> <div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}> <div
{withVariants === 'translations' ? ( className={tcls(
<div className="before:contents[] flex self-start py-2 before:mr-4 before:border-tint before:border-l"> 'w-full',
<TranslationsDropdown 'overflow-x-auto',
context={context} 'no-scrollbar',
siteSpace={siteSpace} '-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
siteSpaces={siteSpaces} !sections ? ['hidden', 'page-no-toc:flex'] : 'flex'
)}
>
<div
className={tcls(
CONTAINER_STYLE,
'grow',
'flex',
'items-end',
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]'
)}
>
{sections.list.some((s) => s.object === 'site-section-group') || // If there's even a single group, show the tabs
sections.list.length > 1 ? ( // Otherwise, show the tabs if there's more than one section
<SiteSectionTabs
sections={encodeClientSiteSections(context, sections)}
/> />
</div> ) : null}
) : null} {withVariants === 'translations' ? (
</SiteSectionTabs> <div className="site-background before:contents[] -mr-4 sm:-mr-6 md:-mr-8 sticky inset-y-0 right-0 z-10 ml-6 flex h-full items-center py-2 pr-4 before:mr-4 before:h-full before:border-tint before:border-l sm:pr-6 md:pr-8">
<TranslationsDropdown
context={context}
siteSpace={siteSpace}
siteSpaces={siteSpaces}
/>
</div>
) : null}
</div>
</div>
</div> </div>
) : null} ) : null}
</header> </header>
@@ -57,7 +57,7 @@ export async function HeaderLink(props: {
); );
} }
if (!link.to) { if (!target || !link.to) {
return null; return null;
} }
@@ -68,7 +68,7 @@ export async function HeaderLink(props: {
headerPreset={headerPreset} headerPreset={headerPreset}
title={link.title} title={link.title}
isDropdown={false} isDropdown={false}
href={target?.href} href={target.href}
/> />
); );
} }
@@ -78,7 +78,7 @@ export type HeaderLinkNavItemProps = {
linkStyle: NonNullable<CustomizationHeaderItem['style']>; linkStyle: NonNullable<CustomizationHeaderItem['style']>;
headerPreset: CustomizationHeaderPreset; headerPreset: CustomizationHeaderPreset;
title: string; title: string;
href?: string; href: string;
isDropdown: boolean; isDropdown: boolean;
} & DropdownButtonProps<HTMLElement>; } & DropdownButtonProps<HTMLElement>;
@@ -165,7 +165,7 @@ function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props; const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
return ( return (
<Link <Link
href={href ?? '#'} href={href}
className={getHeaderLinkClassName({ headerPreset })} className={getHeaderLinkClassName({ headerPreset })}
insights={{ insights={{
type: 'link_click', type: 'link_click',
@@ -76,7 +76,7 @@ export function TranslationsDropdown(props: {
siteSpaces={siteSpaces} siteSpaces={siteSpaces}
variant="blank" variant="blank"
className={tcls( className={tcls(
'-mx-2 bg-transparent px-2 md:py-1 lg:max-w-64 max-md:[&_.button-content]:hidden', '-mx-2 bg-transparent px-2 py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
hasEmojiPrefix hasEmojiPrefix
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop) ? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
: '', : '',
@@ -18,22 +18,17 @@ import {
ActionViewAsPDF, ActionViewAsPDF,
} from './PageActions'; } from './PageActions';
export type PageActionsDropdownURLs = { interface PageActionsDropdownProps {
html: string; siteTitle: string;
markdown: string; markdownPageURL: string;
mcp?: string; mcpURL?: string;
pdf?: string; pdfURL?: string;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
editOnGit?: { editOnGit?: {
provider: GitSyncState['installationProvider']; provider: GitSyncState['installationProvider'];
url: string; url: string;
}; };
};
interface PageActionsDropdownProps {
siteTitle: string;
urls: PageActionsDropdownURLs;
className?: string;
actions: SiteCustomizationSettings['pageActions'];
} }
/** /**
@@ -80,7 +75,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
* Return the list of actions to show in the dropdown menu. * Return the list of actions to show in the dropdown menu.
*/ */
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] { function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
const { siteTitle, urls, actions } = props; const { siteTitle, markdownPageURL, mcpURL, actions } = props;
const assistants = useAI().assistants.filter( const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction (assistant) => assistant.ui === true && assistant.pageAction
); );
@@ -99,45 +94,55 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
<DropdownMenuSeparator className="first:hidden" /> <DropdownMenuSeparator className="first:hidden" />
<ActionCopyMarkdown <ActionCopyMarkdown
isDefaultAction={!assistants.length} isDefaultAction={!assistants.length}
markdownPageURL={urls.markdown} markdownPageURL={markdownPageURL}
type="dropdown-menu-item" type="dropdown-menu-item"
/> />
<ActionViewAsMarkdown markdownPageURL={urls.markdown} type="dropdown-menu-item" /> <ActionViewAsMarkdown markdownPageURL={markdownPageURL} type="dropdown-menu-item" />
</React.Fragment> </React.Fragment>
) : null, ) : null,
actions.externalAI ? ( actions.externalAI ? (
<React.Fragment key="externalAI"> <React.Fragment key="externalAI">
<DropdownMenuSeparator className="first:hidden" /> <DropdownMenuSeparator className="first:hidden" />
<ActionOpenInLLM provider="chatgpt" url={urls.html} type="dropdown-menu-item" /> <ActionOpenInLLM
<ActionOpenInLLM provider="claude" url={urls.html} type="dropdown-menu-item" /> provider="chatgpt"
url={markdownPageURL}
type="dropdown-menu-item"
/>
<ActionOpenInLLM
provider="claude"
url={markdownPageURL}
type="dropdown-menu-item"
/>
</React.Fragment> </React.Fragment>
) : null, ) : null,
actions.mcp && urls.mcp ? ( actions.mcp && mcpURL ? (
<React.Fragment key="mcp"> <React.Fragment key="mcp">
<DropdownMenuSeparator className="first:hidden" /> <DropdownMenuSeparator className="first:hidden" />
<ActionCopyMCPURL mcpURL={urls.mcp} type="dropdown-menu-item" /> <ActionCopyMCPURL mcpURL={mcpURL} type="dropdown-menu-item" />
<ActionOpenMCP <ActionOpenMCP
provider="vscode" provider="vscode"
mcpURL={urls.mcp} mcpURL={mcpURL}
siteTitle={siteTitle} siteTitle={siteTitle}
type="dropdown-menu-item" type="dropdown-menu-item"
/> />
</React.Fragment> </React.Fragment>
) : null, ) : null,
urls.editOnGit || urls.pdf ? ( props.editOnGit || props.pdfURL ? (
<React.Fragment key="editOnGit"> <React.Fragment key="editOnGit">
<DropdownMenuSeparator className="first:hidden" /> <DropdownMenuSeparator className="first:hidden" />
{urls.editOnGit ? ( {props.editOnGit ? (
<ActionOpenEditOnGit <ActionOpenEditOnGit
type="dropdown-menu-item" type="dropdown-menu-item"
provider={urls.editOnGit.provider} provider={props.editOnGit.provider}
url={urls.editOnGit.url} url={props.editOnGit.url}
/> />
) : null} ) : null}
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null} {props.pdfURL ? (
<ActionViewAsPDF url={props.pdfURL} type="dropdown-menu-item" />
) : null}
</React.Fragment> </React.Fragment>
) : null, ) : null,
].filter(Boolean); ].filter(Boolean);
@@ -147,7 +152,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
* A default action shown as a quick-access button beside the dropdown menu * A default action shown as a quick-access button beside the dropdown menu
*/ */
function getPageDefaultAction(props: PageActionsDropdownProps) { function getPageDefaultAction(props: PageActionsDropdownProps) {
const { urls, actions } = props; const { markdownPageURL, actions } = props;
const assistants = useAI().assistants.filter( const assistants = useAI().assistants.filter(
(assistant) => assistant.ui === true && assistant.pageAction (assistant) => assistant.ui === true && assistant.pageAction
); );
@@ -157,12 +162,12 @@ function getPageDefaultAction(props: PageActionsDropdownProps) {
return <ActionOpenAssistant assistant={assistant} type="button" />; return <ActionOpenAssistant assistant={assistant} type="button" />;
} }
if (urls.editOnGit) { if (props.editOnGit) {
return ( return (
<ActionOpenEditOnGit <ActionOpenEditOnGit
type="button" type="button"
provider={urls.editOnGit.provider} provider={props.editOnGit.provider}
url={urls.editOnGit.url} url={props.editOnGit.url}
/> />
); );
} }
@@ -171,7 +176,7 @@ function getPageDefaultAction(props: PageActionsDropdownProps) {
return ( return (
<ActionCopyMarkdown <ActionCopyMarkdown
isDefaultAction={!assistant} isDefaultAction={!assistant}
markdownPageURL={urls.markdown} markdownPageURL={markdownPageURL}
type="button" type="button"
/> />
); );
@@ -35,7 +35,6 @@ export function PageAside(props: {
<aside <aside
className={tcls( className={tcls(
'group/aside', 'group/aside',
'order-last',
'hidden', 'hidden',
'pt-8', 'pt-8',
'pb-4', 'pb-4',
@@ -110,7 +110,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
'opacity-8', 'opacity-8',
'contrast-more:opacity-11', 'contrast-more:opacity-11',
'sidebar-list-default:rounded-l-none!', 'sidebar-list-default:rounded-l-none',
'sidebar-list-default:border-l', 'sidebar-list-default:border-l',
'sidebar-list-default:border-tint', 'sidebar-list-default:border-tint',
], ],
@@ -51,10 +51,10 @@ export function PageBody(props: {
<main <main
className={tcls( className={tcls(
'relative min-w-0 flex-1', 'relative min-w-0 flex-1',
'max-w-screen-2xl py-8', 'mx-auto max-w-screen-2xl py-8',
// Allow words to break if they are too long. // Allow words to break if they are too long.
'break-anywhere', 'break-anywhere',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default', pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default', siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc' page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
)} )}
@@ -81,10 +81,7 @@ export function PageBody(props: {
blockStyle="page-api-block:ml-0" blockStyle="page-api-block:ml-0"
context={{ context={{
mode: 'default', mode: 'default',
contentContext: { contentContext: context,
...context,
page,
},
withLinkPreviews, withLinkPreviews,
}} }}
/> />
@@ -5,10 +5,7 @@ import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import urlJoin from 'url-join'; import urlJoin from 'url-join';
import { getPDFURLSearchParams } from '../PDF'; import { getPDFURLSearchParams } from '../PDF';
import { import { PageActionsDropdown } from '../PageActions/PageActionsDropdown';
PageActionsDropdown,
type PageActionsDropdownURLs,
} from '../PageActions/PageActionsDropdown';
import { PageIcon } from '../PageIcon'; import { PageIcon } from '../PageIcon';
import { StyledLink } from '../primitives'; import { StyledLink } from '../primitives';
@@ -43,7 +40,35 @@ export async function PageHeader(props: {
// Show page actions if *any* of the actions are enabled // Show page actions if *any* of the actions are enabled
<PageActionsDropdown <PageActionsDropdown
siteTitle={context.site.title} siteTitle={context.site.title}
urls={getPageActionsURLs(context, page)} markdownPageURL={`${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`}
editOnGit={
context.customization.git.showEditLink &&
context.space.gitSync?.url &&
page.git
? {
provider: context.space?.gitSync?.installationProvider,
url: urlJoin(context.space.gitSync.url, page.git.path),
}
: undefined
}
pdfURL={
context.customization.pdf.enabled
? context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
)
: undefined
}
mcpURL={
context.site.visibility !== SiteVisibility.VisitorAuth
? context.linker.toAbsoluteURL(
context.linker.toPathInSpace('~gitbook/mcp')
)
: undefined
}
actions={context.customization.pageActions} actions={context.customization.pageActions}
className={tcls( className={tcls(
'float-right ml-4 xl:max-2xl:page-api-block:mr-62', 'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
@@ -121,38 +146,3 @@ export async function PageHeader(props: {
</header> </header>
); );
} }
/**
* Return the URLs for the page actions.
*/
function getPageActionsURLs(
context: GitBookSiteContext,
page: RevisionPageDocument
): PageActionsDropdownURLs {
return {
html: context.linker.toAbsoluteURL(
context.linker.toPathForPage({ pages: context.revision.pages, page })
),
markdown: `${context.linker.toAbsoluteURL(context.linker.toPathInSpace(page.path))}.md`,
editOnGit:
context.customization.git.showEditLink && context.space.gitSync?.url && page.git
? {
provider: context.space?.gitSync?.installationProvider,
url: urlJoin(context.space.gitSync.url, page.git.path),
}
: undefined,
pdf: context.customization.pdf.enabled
? context.linker.toPathInSpace(
`~gitbook/pdf?${getPDFURLSearchParams({
page: page.id,
only: true,
limit: 100,
}).toString()}`
)
: undefined,
mcp:
context.site.visibility !== SiteVisibility.VisitorAuth
? context.linker.toAbsoluteURL(context.linker.toPathInSpace('~gitbook/mcp'))
: undefined,
};
}
@@ -5,7 +5,7 @@ import type React from 'react';
import { TranslateContext } from '@/intl/client'; import { TranslateContext } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations'; import type { TranslationLanguage } from '@/intl/translations';
import { TooltipProvider } from '@radix-ui/react-tooltip'; import { TooltipProvider } from '@radix-ui/react-tooltip';
import { NavigationStatusProvider } from '../hooks'; import { HashProvider } from '../hooks';
import { LoadingStateProvider } from '../primitives/LoadingStateProvider'; import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
/** /**
@@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: {
return ( return (
<TranslateContext.Provider value={language}> <TranslateContext.Provider value={language}>
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<NavigationStatusProvider> <HashProvider>
<LoadingStateProvider>{children}</LoadingStateProvider> <LoadingStateProvider>{children}</LoadingStateProvider>
</NavigationStatusProvider> </HashProvider>
</TooltipProvider> </TooltipProvider>
</TranslateContext.Provider> </TranslateContext.Provider>
); );
@@ -217,7 +217,6 @@
::-webkit-scrollbar { ::-webkit-scrollbar {
background: transparent; background: transparent;
max-width: 8px; max-width: 8px;
max-height: 6px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {

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