mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 06:35:17 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae33c09aa7 | |||
| 7ff69c3b72 | |||
| 6142d6bf80 | |||
| 75543b140f | |||
| 511f1cfbe5 | |||
| 46fa9d954c | |||
| 5e1ed5358e | |||
| 295f03db49 | |||
| bdde3929fc | |||
| 4f4210d24a | |||
| c038ec8f81 | |||
| 75b52eac28 | |||
| ad4d07dc06 | |||
| 75086740f8 | |||
| eea8f1e00f | |||
| 4f9abfb6f2 | |||
| 252afceab0 | |||
| bef191660a | |||
| 145625142d | |||
| f70a2aa310 | |||
| 56e46ce8ab | |||
| 754cc11e0b | |||
| 3551d147e9 | |||
| 162cfe4163 | |||
| 6815dd197e | |||
| d74f8a7098 | |||
| 1e53f46521 | |||
| 1a6216df4f | |||
| a629900324 | |||
| a4c33997fe | |||
| badf3a92f6 | |||
| e0fb9ac3ca | |||
| f3e40410c4 | |||
| 8e99871004 | |||
| 1e4e54e752 | |||
| 50c1be3447 | |||
| 319a1e536c | |||
| 50cb0c6e15 | |||
| ceb32b1a2e | |||
| f8c54b4d34 | |||
| c06ad70f8a | |||
| 0ef5dc8538 | |||
| 28adc9b5f1 | |||
| 912d1693f5 | |||
| c23d4ef331 | |||
| c21693fe50 | |||
| c06b3dd60d | |||
| f173e014db | |||
| 1b458c436e | |||
| b42b0b3910 | |||
| 2fc21272ff | |||
| 379d486488 | |||
| cba583a31c | |||
| 7a504b7cc5 | |||
| 8ec5fd0986 | |||
| 529f940a45 | |||
| 8a3c15911f | |||
| d7948e34b2 | |||
| 9f42211993 | |||
| 65e62307bf | |||
| 469b332da2 | |||
| e434442f1e | |||
| 9344431bfc | |||
| 622ecbec1d | |||
| fa1382b289 | |||
| b8388e5a2a | |||
| 2bce12798f | |||
| f89e889d18 | |||
| 2b8a2d24b6 | |||
| b85eccdff5 | |||
| d924e655c0 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Simplify AI Chat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix robots.txt preventing favicon from being indexed.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Show a toolbar for authenticated users with access to the current site.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Scope search across sections and variants
|
||||
@@ -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
|
||||
|
||||
##### Prerequisites:
|
||||
- Node.js (Version: >=20.6)
|
||||
- Node.js (Version: >= 22.3)
|
||||
- Use `nvm` for easy Node management
|
||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
name: Build (Open Source)
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ''
|
||||
BUN_NPM_TOKEN: ''
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (production)
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (staging)
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
@@ -36,7 +36,6 @@ jobs:
|
||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
|
||||
@@ -25,3 +25,4 @@ yarn-error.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (Version: >=20.6)
|
||||
- Node.js (Version: >= 22.3)
|
||||
- Use nvm for easy Node management
|
||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
|
||||
```
|
||||
.env.local
|
||||
|
||||
NPM_TOKEN_READONLY=xxx
|
||||
BUN_NPM_TOKEN=xxx
|
||||
```
|
||||
|
||||
and then reinstall dependencies.
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[install.scopes]
|
||||
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
||||
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
|
||||
|
||||
+19
-10
@@ -3,20 +3,20 @@
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"turbo": "^2.5.0",
|
||||
"vercel": "^39.3.0"
|
||||
"@changesets/cli": "^2.29.7",
|
||||
"turbo": "^2.5.8",
|
||||
"vercel": "^39.4.2"
|
||||
},
|
||||
"packageManager": "bun@1.2.15",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev": "turbo run dev --concurrency 20",
|
||||
"build": "turbo run build",
|
||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||
"typecheck": "turbo run typecheck",
|
||||
@@ -26,16 +26,25 @@
|
||||
"e2e": "turbo run e2e",
|
||||
"e2e-customers": "turbo run e2e-customers",
|
||||
"changeset": "changeset",
|
||||
"changeset-version": "changeset version && bun run format",
|
||||
"release": "turbo run release && changeset publish",
|
||||
"changeset-version": "changeset version && bun run format && bun update",
|
||||
"release": "turbo run release && bun run publish-all-packages",
|
||||
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "^0.141.0",
|
||||
"bidc": "^0.0.2"
|
||||
"@gitbook/api": "0.143.2",
|
||||
"assert-never": "^1.4.0",
|
||||
"bidc": "^0.0.2",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -8,17 +8,24 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "tsdown",
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,22 +4,27 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.3.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.3.3",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"assert-never": "^1.2.1"
|
||||
"assert-never": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -4,18 +4,23 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.4.0",
|
||||
"sideEffects": false,
|
||||
"version": "0.4.2",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+22
-10
@@ -5,27 +5,39 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js",
|
||||
"standalone": "./dist/standalone/index.js",
|
||||
"react": "./dist/react/index.js"
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"default": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
"react": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"react": "^19.0.0"
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"react": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "bun run build-lib && bun run build-standalone",
|
||||
"build-lib": "tsdown",
|
||||
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"clean": "rm -rf ./dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md", "standalone"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
GetFrameURLOptions,
|
||||
GitBookEmbeddableConfiguration,
|
||||
@@ -17,19 +19,19 @@ export type GitBookFrameProps = {
|
||||
export function GitBookFrame(props: GitBookFrameProps) {
|
||||
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
||||
|
||||
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const gitbook = useGitBook();
|
||||
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
|
||||
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
|
||||
|
||||
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (frameRef.current) {
|
||||
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
||||
}
|
||||
}, [gitbook]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
gitbookFrame?.configure({
|
||||
buttons,
|
||||
welcomeMessage,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: 'src/index.ts',
|
||||
outDir: 'dist',
|
||||
},
|
||||
{
|
||||
entry: 'src/react/index.ts',
|
||||
outDir: 'dist/react',
|
||||
unbundle: true,
|
||||
},
|
||||
]);
|
||||
@@ -1,13 +1,25 @@
|
||||
# @gitbook/emoji-codepoints
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 295f03d: Republish packages
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 57adb3e: Second release to fix publishing with changeset
|
||||
- 57adb3e: Second release to fix publishing with changeset
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5f8a8fe: Initial release
|
||||
- 5f8a8fe: Initial release
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitbook/emoji-codepoints",
|
||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"exports": "./dist/index.ts",
|
||||
"dependencies": {},
|
||||
"sideEffects": false,
|
||||
"devDependencies": {
|
||||
"emoji-assets": "^8.0.0"
|
||||
"emoji-assets": "^9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./build.ts",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+14
-11
@@ -1,37 +1,40 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"assert-never": "catalog:",
|
||||
"escodegen": "^2.1.0",
|
||||
"assert-never": "^1.2.1"
|
||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.20",
|
||||
"tsdown": "^0.15.0",
|
||||
"@types/escodegen": "^0.0.10",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@babel/types": "^7.26.0",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/escodegen": "^0.0.10"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown --project tsconfig.build.json",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test",
|
||||
"clean": "rm -rf ./dist"
|
||||
"clean": "rm -rf ./dist",
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"]
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": ["./tsconfig.json"],
|
||||
"exclude": ["**/*.test.ts"],
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -4,28 +4,33 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"google-font-metadata": "^6.0.3",
|
||||
"typescript": "^5.5.3"
|
||||
"google-font-metadata": "^6.0.7",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./bin/generate.js",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||
"unit": "bun test"
|
||||
},
|
||||
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
||||
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
// Bun Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`getDefaultFont should return correct object for Latin text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should return correct object for Greek text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle mixed script text 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different font weights: regular 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different font weights: bold 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different fonts: inter 1`] = `
|
||||
{
|
||||
"font": "Inter",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
|
||||
{
|
||||
"font": "Roboto",
|
||||
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
|
||||
"url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,5 +1,94 @@
|
||||
# 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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -62,11 +62,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.gmgn.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.spicychat.ai',
|
||||
contentBaseURL: 'https://docs.spicychat.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.spicychat.ai',
|
||||
// contentBaseURL: 'https://docs.spicychat.ai',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.portainer.io',
|
||||
contentBaseURL: 'https://docs.portainer.io',
|
||||
|
||||
@@ -26,8 +26,10 @@ export default class extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||
const startTime = Date.now();
|
||||
const middlewareRequest = new Request(request.url, request);
|
||||
middlewareRequest.headers.set('x-open-next-continent', request.cf?.continent || '');
|
||||
// - `Request`s are handled by the Next server
|
||||
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||
const reqOrResp = await middlewareHandler(middlewareRequest, this.env, this.ctx);
|
||||
if (reqOrResp instanceof Response) {
|
||||
const duration = Date.now() - startTime;
|
||||
const logMessage = formatLog(
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
} from '@opennextjs/aws/types/overrides.js';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
|
||||
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||
|
||||
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
||||
@@ -18,15 +20,12 @@ export type KeyOptions = {
|
||||
|
||||
/**
|
||||
*
|
||||
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
|
||||
* the cache operations. It also integrates both R2 and Cache API in a single class.
|
||||
* Having our own, will allow us to customize it in the future if needed.
|
||||
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
|
||||
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
|
||||
*/
|
||||
class GitbookIncrementalCache implements IncrementalCache {
|
||||
name = 'GitbookIncrementalCache';
|
||||
|
||||
protected localCache: Cache | undefined;
|
||||
|
||||
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||
key: string,
|
||||
cacheType?: CacheType
|
||||
@@ -34,7 +33,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
if (process.env.SHOULD_BYPASS_CACHE === 'true') {
|
||||
// We are in a local middleware environment, we should bypass the cache
|
||||
@@ -42,27 +40,17 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// Check local cache first if available
|
||||
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||
if (localCacheEntry) {
|
||||
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||
CacheValue<CacheType>
|
||||
>;
|
||||
return this.returnNullOn404({
|
||||
...result,
|
||||
// Because we use tag cache and also invalidate them every time,
|
||||
// if we get a cache hit, we don't need to check the tag cache as we already know it's not been revalidated
|
||||
// this should improve performance even further, and reduce costs
|
||||
shouldBypassTagCache: true,
|
||||
});
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
if (!r2Object) return null;
|
||||
|
||||
const json = (await r2Object.json()) as CacheValue<CacheType>;
|
||||
const lastModified = r2Object.uploaded.getTime();
|
||||
|
||||
if (!json) return null;
|
||||
|
||||
return this.returnNullOn404({
|
||||
value: await r2Object.json(),
|
||||
lastModified: r2Object.uploaded.getTime(),
|
||||
value: json,
|
||||
lastModified,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to get from cache', e);
|
||||
@@ -89,39 +77,8 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
): Promise<void> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
|
||||
const localCache = await this.getCacheInstance();
|
||||
|
||||
try {
|
||||
await this.writeToR2(cacheKey, JSON.stringify(value));
|
||||
|
||||
//TODO: Check if there is any places where we don't have tags
|
||||
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||
];
|
||||
|
||||
// We consider R2 as the source of truth, so we update the local cache
|
||||
// only after a successful R2 write
|
||||
await localCache.put(
|
||||
this.getCacheUrlKey(cacheKey),
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value,
|
||||
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||
// In theory we should always get the `revalidate` value
|
||||
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||
'cache-tag': tags.join(','),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to set to cache', e);
|
||||
}
|
||||
@@ -131,14 +88,10 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
const cacheKey = this.getR2Key(key);
|
||||
|
||||
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||
const localCache = await this.getCacheInstance();
|
||||
if (!r2) throw new Error('No R2 bucket');
|
||||
|
||||
try {
|
||||
await r2.delete(cacheKey);
|
||||
|
||||
// Here again R2 is the source of truth, so we delete from local cache first
|
||||
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||
} catch (e) {
|
||||
console.error('Failed to delete from cache', e);
|
||||
}
|
||||
@@ -168,12 +121,6 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheInstance(): Promise<Cache> {
|
||||
if (this.localCache) return this.localCache;
|
||||
this.localCache = await caches.open('incremental-cache');
|
||||
return this.localCache;
|
||||
}
|
||||
|
||||
// Utility function to generate keys for R2/Cache API
|
||||
getR2Key(initialKey: string, cacheType: CacheEntryType = 'cache'): string {
|
||||
let key = initialKey;
|
||||
@@ -189,28 +136,13 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
'/'
|
||||
);
|
||||
}
|
||||
|
||||
getCacheUrlKey(cacheKey: string): string {
|
||||
return `http://cache.local/${cacheKey}`;
|
||||
}
|
||||
|
||||
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
|
||||
entry: CacheValue<CacheType>
|
||||
): string[] | undefined {
|
||||
if ('tags' in entry && entry.tags) {
|
||||
return entry.tags;
|
||||
}
|
||||
|
||||
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
|
||||
const rawTags = entry.meta.headers['x-next-cache-tags'];
|
||||
if (typeof rawTags === 'string') {
|
||||
return rawTags.split(',');
|
||||
}
|
||||
}
|
||||
if ('value' in entry) {
|
||||
return entry.tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GitbookIncrementalCache();
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
@@ -8,13 +8,15 @@
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@opennextjs/cloudflare": "^1.9.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
@@ -26,10 +28,9 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tusbar/cache-control": "^1.0.2",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"assert-never": "catalog:",
|
||||
"bidc": "catalog:",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"classnames": "catalog:",
|
||||
"direction": "^2.0.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
@@ -37,8 +38,8 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
@@ -48,7 +49,7 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"object-identity": "^0.1.2",
|
||||
@@ -70,12 +71,10 @@
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zustand": "^5.0.3",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"zod": "^3"
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
@@ -91,6 +90,7 @@
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/rison": "^0.0.9",
|
||||
"bun-types": "catalog:",
|
||||
"deepmerge": "^4.3.1",
|
||||
"env-cmd": "^10.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -98,7 +98,7 @@
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -120,5 +120,9 @@
|
||||
},
|
||||
"browserslist": [
|
||||
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
||||
]
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
+100
-2
@@ -16,9 +16,107 @@ export async function GET(
|
||||
return new Response(
|
||||
`
|
||||
<html>
|
||||
<head></head>
|
||||
<body></body>
|
||||
<head>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
</head>
|
||||
<body>
|
||||
<svg style="position: absolute; bottom: 6rem; right: 4rem;" width="719" height="644" viewBox="0 0 719 644" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect opacity="0.1" x="0.566895" y="208" width="529.567" height="228" rx="16" fill="#A4A7B0"/>
|
||||
<g opacity="0.2">
|
||||
<line x1="141.567" y1="472" x2="4.56689" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="416.067" y1="472" x2="165.567" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="472" x2="440.067" y2="472" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="194.567" y1="496" x2="4.56689" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="324.067" y1="496" x2="218.567" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="473.567" y1="496" x2="348.067" y2="496" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="231.901" y1="520" x2="4.56689" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="291.186" y1="520" x2="255.901" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="442.058" y1="520" x2="315.186" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="520" x2="466.058" y2="520" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="141.567" y1="544" x2="4.56689" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="348.191" y1="544" x2="165.567" y2="544" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="184.017" y1="592" x2="4.56689" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="312.848" y1="592" x2="208.017" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="386.574" y1="592" x2="336.848" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="448.401" y1="592" x2="410.574" y2="592" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="126.633" y1="616" x2="4.56689" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="401.133" y1="616" x2="150.633" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="616" x2="425.133" y2="616" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="179.457" y1="640" x2="4.56689" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="329.569" y1="640" x2="203.457" y2="640" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="141.567" y1="4" x2="4.56689" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="416.067" y1="4" x2="165.567" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="4" x2="440.067" y2="4" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="194.567" y1="28" x2="4.56689" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="324.067" y1="28" x2="218.567" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="473.567" y1="28" x2="348.067" y2="28" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="231.901" y1="52" x2="4.56689" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="291.186" y1="52" x2="255.901" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="442.058" y1="52" x2="315.186" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="52" x2="466.058" y2="52" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="141.567" y1="76" x2="4.56689" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="348.191" y1="76" x2="165.567" y2="76" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="184.017" y1="124" x2="4.56689" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="312.848" y1="124" x2="208.017" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="386.574" y1="124" x2="336.848" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="448.401" y1="124" x2="410.574" y2="124" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="126.633" y1="148" x2="4.56689" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="401.133" y1="148" x2="150.633" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="526.567" y1="148" x2="425.133" y2="148" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="179.457" y1="172" x2="4.56689" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="329.569" y1="172" x2="203.457" y2="172" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<rect x="563" y="208" width="156" height="256" rx="16" fill="#A4A7B0" opacity="0.05"/>
|
||||
<g opacity="0.2">
|
||||
<line x1="682.214" y1="228" x2="583" y2="228" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="638.472" y1="252" x2="583" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="681.396" y1="252" x2="654.472" y2="252" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="617.989" y1="276" x2="583" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="699" y1="276" x2="633.989" y2="276" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="603.168" y1="300" x2="583" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="657.873" y1="300" x2="619.168" y2="300" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="637" y1="324" x2="583" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="688.836" y1="324" x2="653" y2="324" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="657.474" y1="372" x2="583" y2="372" stroke="#656973" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="633" y1="396" x2="583" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="699" y1="396" x2="649" y2="396" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="585.416" y1="420" x2="583" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="621.731" y1="420" x2="601.416" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="699" y1="420" x2="637.731" y2="420" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g opacity="0.2">
|
||||
<line x1="619.471" y1="444" x2="583" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
<line x1="658.186" y1="444" x2="635.471" y2="444" stroke="#A4A7B0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
</body>
|
||||
<script src="${context.linker.toAbsoluteURL(context.linker.toPathInSite('~gitbook/embed/script.js'))}"></script>
|
||||
<script>
|
||||
window.GitBook('configure', {
|
||||
suggestions: [
|
||||
'Help me get started',
|
||||
'What can I ask you?',
|
||||
'Show me tips and tricks',
|
||||
],
|
||||
});
|
||||
window.GitBook('open');
|
||||
</script>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
|
||||
@@ -21,7 +21,6 @@ export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
content: React.ReactNode;
|
||||
query?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type AIChatPendingTool = {
|
||||
@@ -392,11 +391,11 @@ export function AIChatProvider(props: {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
note: state.messages.length > 0 ? 'Starting a new chat' : undefined,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { Assistant } from '../AI';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
@@ -24,7 +23,7 @@ export function AIChatButton(props: {
|
||||
iconOnly={!showLabel}
|
||||
size="medium"
|
||||
variant="header"
|
||||
className={tcls('h-9 px-2.5')}
|
||||
className="h-9 px-2.5 max-md:[&_.button-content]:hidden"
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
@@ -38,7 +37,7 @@ export function AIChatButton(props: {
|
||||
}
|
||||
onClick={() => assistant.open()}
|
||||
>
|
||||
{showLabel ? <span className="max-md:hidden">{t(language, 'ask')}</span> : null}
|
||||
{showLabel ? t(language, 'ask') : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export function AIChatInput(props: {
|
||||
<span className="leading-none">
|
||||
{t(language, 'ai_chat_context_title')}
|
||||
</span>
|
||||
<Icon icon="question-circle" className="size-3" />
|
||||
<Icon icon="question-circle" className="size-3 shrink-0" />
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
</HoverCardRoot>
|
||||
|
||||
@@ -6,13 +6,14 @@ import type React from 'react';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
export function AIChatMessages(props: {
|
||||
chat: AIChatState;
|
||||
chatController: AIChatController;
|
||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { chat, lastUserMessageRef } = props;
|
||||
const { chat, chatController, lastUserMessageRef } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,63 +24,57 @@ export function AIChatMessages(props: {
|
||||
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
||||
|
||||
return (
|
||||
<>
|
||||
{message.note ? (
|
||||
<div
|
||||
key={`${index}-note`}
|
||||
className="self-center text-tint-subtle text-xs"
|
||||
>
|
||||
{message.note}
|
||||
<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 && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||
{!message.content ? <HoldMessage /> : null}
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : 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 && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||
{!message.content ? <HoldMessage /> : null}
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -4,19 +4,14 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { OpenAPIPrefillContextProvider } from '@gitbook/react-openapi';
|
||||
import * as React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type AdaptiveVisitorClaimsData = {
|
||||
visitor: {
|
||||
claims: Record<string, unknown> & { unsigned: Record<string, unknown> };
|
||||
};
|
||||
};
|
||||
import type { AdaptiveVisitorClaims } from './types';
|
||||
|
||||
/**
|
||||
* In-memory cache of visitor claim readers keyed by contextId.
|
||||
*/
|
||||
const adaptiveVisitorReaderCache = new Map<
|
||||
string,
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaimsData | null>>
|
||||
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
|
||||
>();
|
||||
|
||||
function createResourceReader<T>(promise: Promise<T>) {
|
||||
@@ -52,7 +47,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return await res.json<AdaptiveVisitorClaimsData>();
|
||||
return await res.json<AdaptiveVisitorClaims>();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +59,7 @@ function getAdaptiveVisitorClaimsReader(url: string, contextId: string) {
|
||||
return reader;
|
||||
}
|
||||
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaimsData | null;
|
||||
export type AdaptiveVisitorContextValue = () => AdaptiveVisitorClaims | null;
|
||||
|
||||
const AdaptiveVisitorContext = createContext<AdaptiveVisitorContextValue>(() => null);
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './AdaptiveVisitorContextProvider';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type AdaptiveVisitorClaimsData = Record<string, unknown> & {
|
||||
unsigned: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AdaptiveVisitorClaims = {
|
||||
visitor: {
|
||||
claims: AdaptiveVisitorClaimsData;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Variables } from '@gitbook/api';
|
||||
import type { AdaptiveVisitorClaims } from './types';
|
||||
|
||||
/**
|
||||
* Return an evaluation context to evaluate expressions.
|
||||
*/
|
||||
export function createExpressionEvaluationContext(args: {
|
||||
visitorClaims: AdaptiveVisitorClaims | null;
|
||||
variables: {
|
||||
space?: Variables;
|
||||
page?: Variables;
|
||||
};
|
||||
}) {
|
||||
const { visitorClaims, variables } = args;
|
||||
return {
|
||||
...(visitorClaims ? visitorClaims : {}),
|
||||
space: {
|
||||
vars: variables.space ?? {},
|
||||
},
|
||||
...(variables.page
|
||||
? {
|
||||
page: {
|
||||
vars: variables.page ?? {},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
import {
|
||||
@@ -17,48 +18,108 @@ import {
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
import type { AdminToolbarClientProps } from './types';
|
||||
import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
|
||||
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
|
||||
const {
|
||||
minified,
|
||||
setMinified,
|
||||
shouldAutoExpand,
|
||||
hidden,
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
} = useToolbarVisibility({
|
||||
onPersistentClose,
|
||||
onSessionClose,
|
||||
onToggleMinify,
|
||||
});
|
||||
|
||||
const visitorSession = useVisitorSession();
|
||||
|
||||
const toolbarControls: ToolbarControlsContextValue = {
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
shouldAutoExpand,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If there is a change request, show the change request toolbar
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<ChangeRequestToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<RevisionToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<RevisionToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitorSession?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AuthenticatedUserToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
<ToolbarControlsWrapper value={toolbarControls}>
|
||||
<AuthenticatedUserToolbar
|
||||
context={context}
|
||||
minified={minified}
|
||||
onMinifiedChange={setMinified}
|
||||
/>
|
||||
</ToolbarControlsWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
/**
|
||||
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
|
||||
*/
|
||||
export function ToolbarControlsWrapper(
|
||||
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
|
||||
) {
|
||||
const { children, value } = props;
|
||||
return (
|
||||
<ToolbarControlsProvider value={value}>
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">{children}</MotionConfig>
|
||||
</IframeWrapper>
|
||||
</ToolbarControlsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarViewProps {
|
||||
context: AdminToolbarContext;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { changeRequest, site } = context;
|
||||
if (!changeRequest) {
|
||||
throw new Error('Change request is not set');
|
||||
@@ -71,11 +132,11 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix="Change request"
|
||||
suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
suffix={`${changeRequest.subject || 'Untitled'}`}
|
||||
/>
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
@@ -88,9 +149,13 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButtonGroup>
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Comment in app */}
|
||||
<ToolbarButton
|
||||
title="Comment in a GitBook"
|
||||
@@ -125,16 +190,13 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||
})}
|
||||
icon="code-pull-request"
|
||||
/>
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
</ToolbarButtonGroup>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, site } = context;
|
||||
if (!revision) {
|
||||
throw new Error('Revision is not set');
|
||||
@@ -145,7 +207,7 @@ function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
@@ -157,7 +219,7 @@ function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButtonGroup>
|
||||
<ToolbarActions>
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
title={
|
||||
@@ -205,22 +267,26 @@ function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||
})}
|
||||
icon="code-commit"
|
||||
/>
|
||||
</ToolbarButtonGroup>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
const { context, minified, onMinifiedChange } = props;
|
||||
const { revision, space, site } = context;
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<Toolbar
|
||||
minified={minified}
|
||||
onMinifiedChange={onMinifiedChange}
|
||||
label="Only visible to your GitBook organization"
|
||||
>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site" suffix={context.site.title} />
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
@@ -230,9 +296,14 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButtonGroup>
|
||||
<ToolbarActions>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
href={getToolbarHref({
|
||||
@@ -240,8 +311,10 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gear"
|
||||
icon="gears"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Customize in GitBook"
|
||||
href={getToolbarHref({
|
||||
@@ -251,6 +324,8 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
})}
|
||||
icon="palette"
|
||||
/>
|
||||
|
||||
{/* Open insights in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open insights in GitBook"
|
||||
href={getToolbarHref({
|
||||
@@ -260,12 +335,22 @@ function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
})}
|
||||
icon="chart-simple"
|
||||
/>
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
</ToolbarButtonGroup>
|
||||
</ToolbarActions>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarActions(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<ToolbarButtonGroup>
|
||||
{children}
|
||||
<HideToolbarButton />
|
||||
</ToolbarButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageButton(props: {
|
||||
href: string;
|
||||
siteId: string;
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
--seg-A-color: #2782c4;
|
||||
--seg-B-color: #43b7f2;
|
||||
--seg-C-color: #8be2ff;
|
||||
--trace-color: #46474c;
|
||||
|
||||
|
||||
--T: 2s;
|
||||
shape-rendering: geometricPrecision;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
:global(html.dark) .svgLogo {
|
||||
--logo-fill: var(--color-neutral-800);
|
||||
--seg-A-color: #19537d;
|
||||
--seg-B-color: #2782c4;
|
||||
--seg-C-color: #43b7f2;
|
||||
--trace-color: #46474c;
|
||||
.static .trace {
|
||||
animation: none;
|
||||
fill: var(--logo-fill);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.static .seg {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Base segment animation */
|
||||
@@ -177,4 +182,4 @@
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,16 @@ import { tcls } from '@/lib/tailwind';
|
||||
import type React from 'react';
|
||||
import styles from './AnimatedLogo.module.css';
|
||||
|
||||
export const AnimatedLogo: React.FC = () => {
|
||||
interface AnimatedLogoProps {
|
||||
shouldAnimate?: boolean;
|
||||
}
|
||||
|
||||
export const AnimatedLogo: React.FC<AnimatedLogoProps> = (props) => {
|
||||
const { shouldAnimate = true } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={styles.svgLogo}
|
||||
className={tcls(styles.svgLogo, shouldAnimate ? undefined : styles.static)}
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.arcMenu {
|
||||
--arc-width: 505px;
|
||||
--arc-height: 400px;
|
||||
--arc-radius: 34%;
|
||||
--start-distance: -240px;
|
||||
--spread-distance: 45px;
|
||||
}
|
||||
|
||||
.arcMenuPath {
|
||||
bottom: calc(var(--arc-height) / -2);
|
||||
width: var(--arc-width);
|
||||
height: var(--arc-height);
|
||||
border-radius: var(--arc-radius);
|
||||
transform: translate(0%, 0%);
|
||||
}
|
||||
|
||||
.arcMenuItem {
|
||||
animation-name: hide-toolbar-arc-enter;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
animation-duration: var(--arc-duration, 0.4s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
offset-path: border-box;
|
||||
offset-anchor: 0% 0%;
|
||||
offset-rotate: auto var(--rotation-offset, 0deg);
|
||||
}
|
||||
|
||||
@keyframes hide-toolbar-arc-enter {
|
||||
from {
|
||||
offset-distance: var(--start-distance);
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,39 @@
|
||||
'use client';
|
||||
import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
|
||||
import {
|
||||
AnimatePresence,
|
||||
type MotionValue,
|
||||
motion,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
import React from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
|
||||
import { getCopyVariants, toolbarEasings } from './transitions';
|
||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
export function Toolbar(props: { children: React.ReactNode }) {
|
||||
const [minified, setMinified] = React.useState(true);
|
||||
const [showToolbarControls, setShowToolbarControls] = React.useState(false);
|
||||
interface ToolbarProps {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function Toolbar(props: ToolbarProps) {
|
||||
const { children, label, minified, onMinifiedChange } = props;
|
||||
const controls = useToolbarControls();
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const autoExpandTriggeredRef = React.useRef(false);
|
||||
|
||||
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
||||
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
@@ -31,16 +49,41 @@ export function Toolbar(props: { children: React.ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// After toolbar appears, wait then show the full content
|
||||
// After toolbar appears, wait, then show the full content
|
||||
React.useEffect(() => {
|
||||
if (isReady) {
|
||||
const expandAfterTimeout = setTimeout(() => {
|
||||
setMinified(false);
|
||||
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
|
||||
|
||||
return () => clearTimeout(expandAfterTimeout);
|
||||
if (!isReady || autoExpandTriggeredRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [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
|
||||
if (!isReady) {
|
||||
@@ -48,61 +91,47 @@ export function Toolbar(props: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseEnter={() => setShowToolbarControls(true)}
|
||||
onMouseLeave={() => setShowToolbarControls(false)}
|
||||
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setMinified((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'border-tint-1/3',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]',
|
||||
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
|
||||
)}
|
||||
initial={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
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 />
|
||||
<Tooltip label={label}>
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
|
||||
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
|
||||
)}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
|
||||
{!minified ? props.children : null}
|
||||
|
||||
{!minified && showToolbarControls && <MinifyButton setMinified={setMinified} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,12 +158,15 @@ export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
|
||||
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
|
||||
>
|
||||
{buttonChildren.map((child, index) => {
|
||||
const motionValues = buttonMotionValues[index];
|
||||
const childEl = child as React.ReactElement;
|
||||
return React.cloneElement(childEl, {
|
||||
key: index,
|
||||
motionValues,
|
||||
});
|
||||
const childKey = childEl.key ?? `toolbar-button-${index}`;
|
||||
return (
|
||||
<ToolbarButtonWrapper
|
||||
key={childKey}
|
||||
child={childEl}
|
||||
rawMotionValues={buttonMotionValues[index]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
@@ -148,15 +180,27 @@ export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorEleme
|
||||
icon: IconName;
|
||||
iconClassName?: string;
|
||||
title?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolbarButton(props: ToolbarButtonProps) {
|
||||
const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
|
||||
props;
|
||||
export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps>((props, ref) => {
|
||||
const {
|
||||
title,
|
||||
disabled,
|
||||
motionValues,
|
||||
className,
|
||||
style,
|
||||
href,
|
||||
onClick,
|
||||
icon,
|
||||
iconClassName,
|
||||
children,
|
||||
} = props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild}>
|
||||
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
||||
{children ? children : null}
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
@@ -189,39 +233,77 @@ export function ToolbarButton(props: ToolbarButtonProps) {
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'border-neutral-500',
|
||||
'outline-neutral-800',
|
||||
'outline-1',
|
||||
'border',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'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' : '',
|
||||
'shadow-1xs'
|
||||
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
|
||||
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} className={tcls('size-4', iconClassName)} />
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
ToolbarButton.displayName = 'ToolbarButton';
|
||||
|
||||
function ToolbarButtonWrapper(props: {
|
||||
child: React.ReactElement;
|
||||
rawMotionValues?: { scale: MotionValue<number>; x: MotionValue<number> };
|
||||
}) {
|
||||
const { child, rawMotionValues } = props;
|
||||
|
||||
// Convert the raw motion values to smooth spring easings
|
||||
const springScale = useSpring(rawMotionValues?.scale.get() ?? 1, {
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
});
|
||||
const springX = useSpring(rawMotionValues?.x.get() ?? 0, { stiffness: 400, damping: 30 });
|
||||
|
||||
// Sync springs with raw motion values
|
||||
React.useEffect(() => {
|
||||
if (!rawMotionValues) return;
|
||||
|
||||
const unsubScale = rawMotionValues.scale.on('change', (v) => springScale.set(v));
|
||||
const unsubX = rawMotionValues.x.on('change', (v) => springX.set(v));
|
||||
|
||||
return () => {
|
||||
unsubScale();
|
||||
unsubX();
|
||||
};
|
||||
}, [rawMotionValues, springScale, springX]);
|
||||
|
||||
const motionValues = {
|
||||
scale: springScale,
|
||||
x: springX,
|
||||
};
|
||||
|
||||
return React.cloneElement(child, {
|
||||
motionValues,
|
||||
});
|
||||
}
|
||||
|
||||
export function ToolbarSeparator() {
|
||||
return <div className="h-5 w-px bg-tint-1/3" />;
|
||||
}
|
||||
|
||||
export function ToolbarTitle(props: { prefix: string; suffix: string }) {
|
||||
export function ToolbarTitle(props: { prefix?: string; suffix: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs ">
|
||||
<ToolbarTitlePrefix title={props.prefix} />
|
||||
{props.prefix ? <ToolbarTitlePrefix title={props.prefix} /> : null}
|
||||
<ToolbarTitleSuffix title={props.suffix} />
|
||||
</div>
|
||||
);
|
||||
@@ -231,7 +313,7 @@ function ToolbarTitlePrefix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(0)}
|
||||
className="font-light text-neutral-7 dark:text-neutral-3"
|
||||
className="truncate font-medium text-neutral-1 dark:text-neutral-12"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
@@ -242,7 +324,7 @@ function ToolbarTitleSuffix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
|
||||
className="max-w-[20ch] truncate text-neutral-1 dark:text-neutral-12"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
@@ -253,36 +335,9 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-7 text-xxs dark:text-neutral-2"
|
||||
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
>
|
||||
{props.subtitle}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
export interface ToolbarControlsContextValue {
|
||||
minimize: () => void;
|
||||
closeSession?: () => void;
|
||||
closePersistent?: () => void;
|
||||
shouldAutoExpand?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarControlsContext = React.createContext<ToolbarControlsContextValue | null>(null);
|
||||
|
||||
/*
|
||||
* Provides reusable state setters (mainly for hiding/showing the toolbar) for the toolbar controls propagated through to the children
|
||||
*/
|
||||
export function ToolbarControlsProvider(
|
||||
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
|
||||
) {
|
||||
const { children, value } = props;
|
||||
return (
|
||||
<ToolbarControlsContext.Provider value={value}>{children}</ToolbarControlsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToolbarControls() {
|
||||
return React.useContext(ToolbarControlsContext);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './AdminToolbarClient';
|
||||
export * from './IframeWrapper';
|
||||
export * from './Toolbar';
|
||||
export * from './transitions';
|
||||
export * from './utils';
|
||||
|
||||
@@ -54,4 +54,7 @@ export type AdminToolbarContext = {
|
||||
|
||||
export interface AdminToolbarClientProps {
|
||||
context: AdminToolbarContext;
|
||||
onSessionClose?: () => void;
|
||||
onPersistentClose?: () => void;
|
||||
onToggleMinify?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { type MotionValue, motionValue } from 'framer-motion';
|
||||
import { type MotionValue, motionValue } from 'motion/react';
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonMotionValues {
|
||||
scale: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
}
|
||||
|
||||
interface MagnificationConfig {
|
||||
/** Size of each button in pixels - used for spacing calculations */
|
||||
buttonSize?: number;
|
||||
@@ -23,21 +18,16 @@ interface MagnificationConfig {
|
||||
|
||||
const defaultConfig: Required<MagnificationConfig> = {
|
||||
buttonSize: 32, // Standard button size for 32px (size-8) buttons
|
||||
maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming
|
||||
maxScale: 1.25, // 25% scale increase - noticeable but not overwhelming
|
||||
influenceRadius: 80, // ~2.5 button widths of influence
|
||||
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
|
||||
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
|
||||
padding: 10, // Small buffer zone around container edges
|
||||
};
|
||||
|
||||
// Helper functions for cleaner code
|
||||
const createMotionValues = (count: number): ButtonMotionValues[] =>
|
||||
Array.from({ length: count }, () => ({
|
||||
scale: motionValue(1),
|
||||
x: motionValue(0),
|
||||
}));
|
||||
|
||||
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
|
||||
const resetMotionValues = (
|
||||
motionValues: Array<{ scale: MotionValue<number>; x: MotionValue<number> }>
|
||||
) => {
|
||||
motionValues.forEach(({ scale, x }) => {
|
||||
scale.set(1);
|
||||
x.set(0);
|
||||
@@ -46,10 +36,17 @@ const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
|
||||
|
||||
const captureButtonPositions = (buttons: HTMLElement[]) => {
|
||||
// Reset transforms to get original positions
|
||||
buttons.forEach((button) => (button.style.transform = ''));
|
||||
buttons.forEach((button) => {
|
||||
button.style.transform = '';
|
||||
});
|
||||
return buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return { left: rect.left, width: rect.width };
|
||||
return {
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
top: rect.top,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -59,26 +56,46 @@ const calculateScale = (
|
||||
containerRect: DOMRect,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
// Calculate only X-axis distance from mouse to button center
|
||||
const distance = Math.abs(mouseX - buttonCenterX);
|
||||
|
||||
if (distance > config.influenceRadius) return 1;
|
||||
|
||||
// Edge influence to reduce scaling near container edges
|
||||
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
|
||||
const edgeInfluence = Math.min(distanceFromEdge / 20, 1);
|
||||
// Calculate distance from container edge (X-axis only)
|
||||
const distanceFromEdgeX = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
|
||||
const distanceFromEdge = distanceFromEdgeX;
|
||||
|
||||
// Exponential scaling curve
|
||||
const progress = distance / config.influenceRadius;
|
||||
const exponentialProgress = (1 - progress) ** config.scaleExponent;
|
||||
const baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
|
||||
// Define the "heart" zone - 8px from center (16x16px total area)
|
||||
const heartZoneRadius = 8;
|
||||
|
||||
return 1 + (baseScale - 1) * edgeInfluence;
|
||||
let baseScale: number;
|
||||
|
||||
if (distance <= heartZoneRadius) {
|
||||
// Within the heart zone - always at max scale
|
||||
baseScale = config.maxScale;
|
||||
} else {
|
||||
// Outside heart zone - scale from max to 1 based on distance
|
||||
const outerRadius = config.influenceRadius - heartZoneRadius;
|
||||
const outerDistance = distance - heartZoneRadius;
|
||||
const progress = Math.min(outerDistance / outerRadius, 1);
|
||||
const exponentialProgress = (1 - progress) ** config.scaleExponent;
|
||||
baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
|
||||
}
|
||||
|
||||
// Only apply entry transition if we're very close to the edge (within 10px)
|
||||
// This prevents the entry logic from interfering with normal scaling
|
||||
if (distanceFromEdge < 10) {
|
||||
const entryProgress = distanceFromEdge / 10;
|
||||
return 1 + (baseScale - 1) * entryProgress;
|
||||
}
|
||||
|
||||
return baseScale;
|
||||
};
|
||||
|
||||
const calculateSpacing = (
|
||||
buttonIndex: number,
|
||||
buttonEffects: Array<{ scale: number; translateX: number }>,
|
||||
positions: Array<{ left: number; width: number }>,
|
||||
positions: Array<{ left: number; width: number; top: number; height: number }>,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
const currentPos = positions[buttonIndex];
|
||||
@@ -123,10 +140,19 @@ export function useMagnificationEffect(props: {
|
||||
config?: MagnificationConfig;
|
||||
}) {
|
||||
const { childrenCount, containerRef, config } = props;
|
||||
const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
|
||||
const originalPositionsRef = React.useRef<Array<{ left: number; width: number }>>([]);
|
||||
const originalPositionsRef = React.useRef<
|
||||
Array<{ left: number; width: number; top: number; height: number }>
|
||||
>([]);
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
const finalConfig = React.useMemo(() => ({ ...defaultConfig, ...config }), [config]);
|
||||
|
||||
// Create basic motion values that will be consumed by springs in the components
|
||||
const buttonMotionValues = React.useMemo(() => {
|
||||
return Array.from({ length: childrenCount }, () => ({
|
||||
scale: motionValue(1),
|
||||
x: motionValue(0),
|
||||
}));
|
||||
}, [childrenCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -141,11 +167,6 @@ export function useMagnificationEffect(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize motion values if button count changed
|
||||
if (buttonMotionValues.length !== buttons.length) {
|
||||
setButtonMotionValues(createMotionValues(buttons.length));
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const buttons = Array.from(
|
||||
container.querySelectorAll('.toolbar-button')
|
||||
@@ -210,17 +231,7 @@ export function useMagnificationEffect(props: {
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
};
|
||||
}, [
|
||||
finalConfig.buttonSize,
|
||||
finalConfig.maxScale,
|
||||
finalConfig.influenceRadius,
|
||||
finalConfig.spacingMultiplier,
|
||||
finalConfig.scaleExponent,
|
||||
finalConfig.padding,
|
||||
containerRef,
|
||||
buttonMotionValues,
|
||||
childrenCount,
|
||||
]);
|
||||
}, [finalConfig, containerRef, buttonMotionValues, childrenCount]);
|
||||
|
||||
return { buttonMotionValues };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
getLocalStorageItem,
|
||||
getSessionStorageItem,
|
||||
removeSessionStorageItem,
|
||||
setLocalStorageItem,
|
||||
setSessionStorageItem,
|
||||
} from '@/lib/browser/local-storage';
|
||||
|
||||
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
||||
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
||||
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
||||
|
||||
type SessionHideReason = 'session' | 'persistent';
|
||||
|
||||
/**
|
||||
* Read the current session hide state. We store whether the toolbar was hidden “for session” or
|
||||
* “persistently” so we can distinguish between a temporary dismissal and a user preference.
|
||||
*/
|
||||
export const getSessionHideState = (): { hidden: boolean; reason?: SessionHideReason } => {
|
||||
const value = getSessionStorageItem<SessionHideReason | null>(SESSION_STORAGE_KEY, null);
|
||||
if (value === 'session' || value === 'persistent') {
|
||||
return { hidden: true, reason: value };
|
||||
}
|
||||
return { hidden: false, reason: undefined };
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the session hide state. Passing `false` clears the stored preference entirely.
|
||||
*/
|
||||
export const setSessionHidden = (value: boolean, reason?: SessionHideReason) => {
|
||||
if (value && reason) {
|
||||
setSessionStorageItem(SESSION_STORAGE_KEY, reason);
|
||||
} else {
|
||||
removeSessionStorageItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the last minified state from session storage. If no value was ever stored we return
|
||||
* `undefined`, which signals that the toolbar should auto-expand once the logo animation finishes.
|
||||
*/
|
||||
export const getStoredMinified = (): boolean | undefined => {
|
||||
const stored = getSessionStorageItem<boolean | null>(SESSION_MINIFIED_KEY, null);
|
||||
if (stored === null || stored === undefined) {
|
||||
removeSessionStorageItem(SESSION_MINIFIED_KEY);
|
||||
return undefined;
|
||||
}
|
||||
return stored;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the current minified state for the ongoing session.
|
||||
*/
|
||||
export const setStoredMinified = (value: boolean) => {
|
||||
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
||||
};
|
||||
|
||||
interface UseToolbarVisibilityOptions {
|
||||
onPersistentClose?: () => void;
|
||||
onSessionClose?: () => void;
|
||||
onToggleMinify?: () => void;
|
||||
}
|
||||
|
||||
export function useToolbarVisibility(options: UseToolbarVisibilityOptions = {}) {
|
||||
const { onPersistentClose, onSessionClose, onToggleMinify } = options;
|
||||
|
||||
const [initialStoredMinified] = React.useState<boolean | undefined>(() =>
|
||||
typeof window !== 'undefined' ? getStoredMinified() : undefined
|
||||
);
|
||||
const shouldAutoExpand = initialStoredMinified === undefined;
|
||||
|
||||
const [minified, setMinifiedState] = React.useState<boolean>(() =>
|
||||
initialStoredMinified !== undefined ? initialStoredMinified : true
|
||||
);
|
||||
|
||||
const [persistentHidden, setPersistentHidden] = React.useState(() =>
|
||||
getLocalStorageItem(STORAGE_KEY, false)
|
||||
);
|
||||
|
||||
const [sessionReason, setSessionReason] = React.useState<SessionHideReason | undefined>(() => {
|
||||
const state = getSessionHideState();
|
||||
return state.hidden ? state.reason : undefined;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStorage = () => {
|
||||
setPersistentHidden(getLocalStorageItem(STORAGE_KEY, false));
|
||||
const state = getSessionHideState();
|
||||
setSessionReason(state.hidden ? state.reason : undefined);
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (persistentHidden) {
|
||||
if (sessionReason !== 'persistent') {
|
||||
// Sync the session flag so we honour the persistent preference and avoid flashes when
|
||||
// opening new tabs in the same browsing session.
|
||||
setSessionHidden(true, 'persistent');
|
||||
setSessionReason('persistent');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionReason === 'persistent') {
|
||||
// If the persistent preference was cleared we also clear the session flag so the toolbar
|
||||
// can reappear immediately without requiring a full reload.
|
||||
setSessionHidden(false);
|
||||
setSessionReason(undefined);
|
||||
}
|
||||
}, [persistentHidden, sessionReason]);
|
||||
|
||||
const setMinified = (value: boolean) => {
|
||||
setMinifiedState(value);
|
||||
setStoredMinified(value);
|
||||
onToggleMinify?.();
|
||||
};
|
||||
|
||||
const minimize = () => setMinified(true);
|
||||
|
||||
const closeSession = () => {
|
||||
setSessionHidden(true, 'session');
|
||||
setSessionReason('session');
|
||||
onSessionClose?.();
|
||||
};
|
||||
|
||||
const closePersistent = () => {
|
||||
setLocalStorageItem(STORAGE_KEY, true);
|
||||
setPersistentHidden(true);
|
||||
setSessionHidden(true, 'persistent');
|
||||
setSessionReason('persistent');
|
||||
onPersistentClose?.();
|
||||
};
|
||||
|
||||
const hidden = persistentHidden || sessionReason === 'session';
|
||||
|
||||
return {
|
||||
minified,
|
||||
setMinified,
|
||||
shouldAutoExpand,
|
||||
hidden,
|
||||
minimize,
|
||||
closeSession,
|
||||
closePersistent,
|
||||
};
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-screen-2xl',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function Caption(
|
||||
wrapperStyle?: ClassValue;
|
||||
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
|
||||
withBorder?: boolean;
|
||||
withFrame?: boolean;
|
||||
} & DocumentContextProps
|
||||
) {
|
||||
const {
|
||||
@@ -33,6 +34,7 @@ export function Caption(
|
||||
context,
|
||||
fit = false,
|
||||
withBorder = false,
|
||||
withFrame = false,
|
||||
wrapperStyle = [
|
||||
'relative',
|
||||
'overflow-hidden',
|
||||
@@ -44,6 +46,7 @@ export function Caption(
|
||||
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'
|
||||
: null,
|
||||
withFrame && 'p-2',
|
||||
],
|
||||
style,
|
||||
} = props;
|
||||
@@ -62,7 +65,14 @@ export function Caption(
|
||||
return (
|
||||
<picture className={tcls('relative', style)}>
|
||||
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
|
||||
<figcaption className={tcls('text-sm', 'text-center', 'mt-2', 'text-tint')}>
|
||||
<figcaption
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'text-center',
|
||||
'text-tint',
|
||||
withFrame ? 'mt-0.5 mb-2.5' : 'mt-2'
|
||||
)}
|
||||
>
|
||||
<Inlines
|
||||
nodes={captionParagraph.nodes}
|
||||
document={document}
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import type { HighlightLine, RenderedInline } from './highlight';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -20,11 +23,21 @@ 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.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const { block, style, inlines, inlineExprVariables } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const isInViewportRef = useRef(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 [highlighting, setHighlighting] = useState(false);
|
||||
|
||||
@@ -80,7 +93,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
if (typeof window !== 'undefined') {
|
||||
setHighlighting(true);
|
||||
import('./highlight').then(({ highlight }) => {
|
||||
highlight(block, inlines).then((lines) => {
|
||||
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +111,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
|
||||
// Otherwise if the block is not in viewport, we reset to the plain lines
|
||||
setLines(null);
|
||||
}, [isInViewport, block, inlines]);
|
||||
}, [isInViewport, block, inlines, evaluateInlineExpression]);
|
||||
|
||||
return (
|
||||
<CodeBlockRenderer
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { getNodeFragmentByType } from '@/lib/document';
|
||||
@@ -14,32 +16,64 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
|
||||
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, isEstimatedOffscreen, context } = props;
|
||||
const inlines = getInlines(block);
|
||||
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
|
||||
const body = (() => {
|
||||
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
|
||||
if (!fragment) {
|
||||
return null;
|
||||
|
||||
let hasInlineExpression = false;
|
||||
|
||||
const richInlines: RenderedInline[] = inlines
|
||||
// Exclude inline expressions from rendered inline as they are rendered as code text once evaluated
|
||||
// and so need to be treated as plain code tokens.
|
||||
.filter((inline) => {
|
||||
if (inline.inline.type === 'expression') {
|
||||
hasInlineExpression = true;
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
<Blocks
|
||||
key={index}
|
||||
document={document}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style="space-y-4"
|
||||
/>
|
||||
);
|
||||
})();
|
||||
return true;
|
||||
})
|
||||
.map((inline, index) => {
|
||||
const body = (() => {
|
||||
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
|
||||
if (!fragment) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Blocks
|
||||
key={index}
|
||||
document={document}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style="space-y-4"
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
||||
return { inline, body };
|
||||
});
|
||||
return { inline, body };
|
||||
});
|
||||
|
||||
if (!isEstimatedOffscreen) {
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression) {
|
||||
// In v2, we render the code block server-side
|
||||
const lines = await highlight(block, richInlines);
|
||||
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
}
|
||||
|
||||
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
const variables = context.contentContext
|
||||
? {
|
||||
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
|
||||
ref={ref}
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls('group/codeblock grid grid-flow-col', style)}
|
||||
className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
||||
{title ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { DocumentBlockCode, JSONDocument } from '@gitbook/api';
|
||||
import type { JSONDocument } from '@gitbook/api';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { convertCodeStringToBlock } from './utils';
|
||||
|
||||
/**
|
||||
* Plain code block with syntax highlighting.
|
||||
@@ -11,31 +12,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
|
||||
const { code, syntax } = props;
|
||||
const id = useId();
|
||||
|
||||
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 block = convertCodeStringToBlock({ key: id, code, syntax });
|
||||
|
||||
const document: JSONDocument = {
|
||||
object: 'document',
|
||||
|
||||
@@ -64,7 +64,10 @@ export async function preloadHighlight(block: DocumentBlockCode) {
|
||||
*/
|
||||
export async function highlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[]
|
||||
inlines: RenderedInline[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): Promise<HighlightLine[]> {
|
||||
const langName = getBlockLang(block);
|
||||
|
||||
@@ -74,10 +77,10 @@ export async function highlight(
|
||||
// - TEMP : language is PowerShell or C++ and browser is Safari:
|
||||
// RegExp#[Symbol.search] throws TypeError when `lastIndex` isn’t writable
|
||||
// Fixed in upcoming Safari 18.6, remove when it'll be released - RND-7772
|
||||
return plainHighlight(block, inlines);
|
||||
return plainHighlight(block, inlines, options);
|
||||
}
|
||||
|
||||
const code = getPlainCodeBlock(block);
|
||||
const code = getPlainCodeBlock(block, undefined, options);
|
||||
|
||||
const highlighter = await getSingletonHighlighter({
|
||||
langs: [langName],
|
||||
@@ -255,11 +258,17 @@ function matchTokenAndInlines(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
|
||||
function getPlainCodeBlock(
|
||||
code: DocumentBlockCode,
|
||||
inlines?: InlineIndexed[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
code.nodes.forEach((node, index) => {
|
||||
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
|
||||
const lineContent = getPlainCodeBlockLine(node, content.length, inlines, options);
|
||||
content += lineContent;
|
||||
|
||||
if (index < code.nodes.length - 1) {
|
||||
@@ -273,7 +282,10 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
|
||||
function getPlainCodeBlockLine(
|
||||
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
|
||||
index: number,
|
||||
inlines?: InlineIndexed[]
|
||||
inlines?: InlineIndexed[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
@@ -284,7 +296,12 @@ function getPlainCodeBlockLine(
|
||||
switch (node.type) {
|
||||
case 'annotation': {
|
||||
const start = index + content.length;
|
||||
content += getPlainCodeBlockLine(node, index + content.length, inlines);
|
||||
content += getPlainCodeBlockLine(
|
||||
node,
|
||||
index + content.length,
|
||||
inlines,
|
||||
options
|
||||
);
|
||||
const end = index + content.length;
|
||||
|
||||
if (inlines) {
|
||||
@@ -297,6 +314,19 @@ function getPlainCodeBlockLine(
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -9,9 +9,13 @@ import type { HighlightLine, HighlightToken, RenderedInline } from './highlight'
|
||||
*/
|
||||
export function plainHighlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[]
|
||||
inlines: RenderedInline[],
|
||||
options?: {
|
||||
evaluateInlineExpression?: (expr: string) => string;
|
||||
}
|
||||
): HighlightLine[] {
|
||||
const inlinesCopy = Array.from(inlines);
|
||||
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
|
||||
if (node.object === 'text') {
|
||||
@@ -20,6 +24,14 @@ export function plainHighlight(
|
||||
content: getNodeText(node),
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'expression') {
|
||||
return {
|
||||
type: 'plain',
|
||||
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const inline = inlinesCopy.shift();
|
||||
return {
|
||||
type: 'annotation',
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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,17 +1,57 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type DocumentBlockColumns, type Length, VerticalAlignment } from '@gitbook/api';
|
||||
import React from 'react';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
|
||||
export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
||||
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 (
|
||||
<div className={tcls('flex flex-col gap-x-8 md:flex-row', style)}>
|
||||
{block.nodes.map((columnBlock) => {
|
||||
<div
|
||||
className={tcls(
|
||||
'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 (
|
||||
<Column
|
||||
key={columnBlock.key}
|
||||
width={columnBlock.data.width}
|
||||
width={
|
||||
columnBlock.data.width ??
|
||||
(columnWidth
|
||||
? { value: columnWidth * 100, unit: '%' }
|
||||
: { value: 0, unit: '%' })
|
||||
}
|
||||
verticalAlignment={columnBlock.data.verticalAlignment}
|
||||
>
|
||||
<Blocks
|
||||
@@ -31,11 +71,11 @@ export function Columns(props: BlockProps<DocumentBlockColumns>) {
|
||||
|
||||
export function Column(props: {
|
||||
children?: React.ReactNode;
|
||||
width?: Length;
|
||||
width: Length;
|
||||
verticalAlignment?: VerticalAlignment;
|
||||
}) {
|
||||
const { width, verticalAlignment } = props;
|
||||
const { className, style } = transformLengthToCSS(width);
|
||||
const { className, style } = transformLengthToCSS(width) ?? {};
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -53,41 +93,25 @@ export function Column(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function transformLengthToCSS(length: Length | undefined) {
|
||||
if (!length) {
|
||||
return { className: ['md:w-full'] }; // default to full width if no length is specified
|
||||
}
|
||||
const COLUMN_DIVISIONS = 12;
|
||||
|
||||
export function transformLengthToCSS(length: Length) {
|
||||
if (typeof length === 'number') {
|
||||
return { style: undefined }; // not implemented yet with non-percentage lengths
|
||||
return; // not implemented yet with non-percentage lengths
|
||||
}
|
||||
|
||||
if (length.unit === '%') {
|
||||
return {
|
||||
className: [
|
||||
'md:shrink-0',
|
||||
COLUMN_WIDTHS[Math.round(length.value * 0.01 * (COLUMN_WIDTHS.length - 1))],
|
||||
],
|
||||
className: 'md:flex-shrink-0 md:[grid-column:var(--grid-col)]',
|
||||
style: {
|
||||
'--grid-col': `auto / span ${Math.round(length.value * 0.01 * COLUMN_DIVISIONS)}`,
|
||||
} as React.CSSProperties,
|
||||
};
|
||||
}
|
||||
|
||||
return { style: undefined }; // not implemented yet with non-percentage lengths
|
||||
}
|
||||
|
||||
// Tailwind CSS classes for column widths.
|
||||
// The index of the array corresponds to the percentage width of the column.
|
||||
const COLUMN_WIDTHS = [
|
||||
'md:w-0',
|
||||
'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',
|
||||
];
|
||||
function getFractionalWidth(length: Length) {
|
||||
if (typeof length === 'number') {
|
||||
return 0;
|
||||
}
|
||||
return length.value / 100;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
id = context.getId ? context.getId(id) : id;
|
||||
|
||||
return (
|
||||
<Details id={id} open={context.mode === 'print'} className={style}>
|
||||
<Details
|
||||
id={id}
|
||||
open={context.mode === 'print'}
|
||||
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
|
||||
>
|
||||
<summary
|
||||
className={tcls(
|
||||
'cursor-pointer',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Icon, type IconName } from '@gitbook/icons';
|
||||
|
||||
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 { Blocks } from './Blocks';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
@@ -18,6 +20,12 @@ export function Hint({
|
||||
const firstLine = getBlockTextStyle(firstNode);
|
||||
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 (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -38,6 +46,8 @@ export function Hint({
|
||||
|
||||
style
|
||||
)}
|
||||
aria-label={label}
|
||||
role="note"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
|
||||
@@ -11,8 +11,8 @@ import type { DocumentContext } from './DocumentView';
|
||||
export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
const { document, block, style, context, isEstimatedOffscreen } = props;
|
||||
|
||||
const isMultipleImages = block.nodes.length > 1;
|
||||
const { align = 'center' } = block.data;
|
||||
const hasMultipleImages = block.nodes.length > 1;
|
||||
const { align = 'center', withFrame } = block.data;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -24,7 +24,15 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
align === 'center' && 'justify-center',
|
||||
align === 'right' && 'justify-end',
|
||||
align === 'left' && 'justify-start',
|
||||
isMultipleImages && ['grid', 'grid-flow-col']
|
||||
hasMultipleImages && ['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) => (
|
||||
@@ -36,6 +44,7 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
siblings={block.nodes.length}
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
withFrame={withFrame}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -62,8 +71,9 @@ async function ImageBlock(props: {
|
||||
context: DocumentContext;
|
||||
siblings: number;
|
||||
isEstimatedOffscreen: boolean;
|
||||
withFrame?: boolean;
|
||||
}) {
|
||||
const { block, context, isEstimatedOffscreen } = props;
|
||||
const { block, context, isEstimatedOffscreen, withFrame } = props;
|
||||
|
||||
const [src, darkSrc] = await Promise.all([
|
||||
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
|
||||
@@ -77,33 +87,66 @@ async function ImageBlock(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<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'),
|
||||
}}
|
||||
/>
|
||||
</Caption>
|
||||
<div className={tcls('relative', 'overflow-hidden')}>
|
||||
{/* Frame grid */}
|
||||
{withFrame && (
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'-top-0.5',
|
||||
'-left-0.5',
|
||||
'right-px',
|
||||
'bottom-px',
|
||||
'opacity-40',
|
||||
'dark:opacity-[0.1]',
|
||||
'bg-[length:24px_24px,24px_24px]',
|
||||
'bg-[linear-gradient(to_right,_rgb(234,235,238)_1px,_transparent_1px),linear-gradient(to_bottom,_rgb(234,235,238)_1px,_transparent_1px)]',
|
||||
'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'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Shadow overlay */}
|
||||
{withFrame && (
|
||||
<div
|
||||
className={tcls(
|
||||
'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 {...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,6 +5,7 @@ import { Annotation } from './Annotation/Annotation';
|
||||
import type { DocumentContextProps } from './DocumentView';
|
||||
import { Emoji } from './Emoji';
|
||||
import { InlineButton } from './InlineButton';
|
||||
import { InlineExpression } from './InlineExpression';
|
||||
import { InlineIcon } from './InlineIcon';
|
||||
import { InlineImage } from './InlineImage';
|
||||
import { InlineLink } from './InlineLink';
|
||||
@@ -51,9 +52,7 @@ export function Inline<T extends DocumentInline>(props: InlineProps<T>) {
|
||||
case 'icon':
|
||||
return <InlineIcon {...contextProps} inline={inline} />;
|
||||
case 'expression':
|
||||
// The GitBook API should take care of evaluating expressions.
|
||||
// We should never need to render them.
|
||||
return null;
|
||||
return <InlineExpression {...contextProps} inline={inline} />;
|
||||
default:
|
||||
return nullIfNever(inline);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
'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}</>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './InlineExpression';
|
||||
export * from './types';
|
||||
export * from './useEvaluateInlineExpression';
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Variables } from '@gitbook/api';
|
||||
|
||||
export interface InlineExpressionVariables {
|
||||
space?: Variables;
|
||||
page?: Variables;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
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,10 +1,23 @@
|
||||
import type { DocumentInlineIcon } from '@gitbook/api';
|
||||
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { InlineProps } from './Inline';
|
||||
import { textColorToStyle } from './utils/colors';
|
||||
|
||||
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
|
||||
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 <Icon icon={inline.data.icon as IconName} className="inline size-[1em]" />;
|
||||
return (
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
alt={inline.data.caption ?? ''}
|
||||
alt={inline.data.alt ?? inline.data.caption ?? ''}
|
||||
sizes={sizes}
|
||||
resize={context.contentContext?.imageResizer}
|
||||
sources={{
|
||||
|
||||
@@ -143,11 +143,11 @@
|
||||
}
|
||||
|
||||
.openapi-column-preview {
|
||||
@apply flex flex-col flex-1;
|
||||
@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);
|
||||
}
|
||||
|
||||
.openapi-column-preview-body {
|
||||
@apply flex flex-col gap-4 sticky top-[calc(var(--toc-top-offset)+5rem)] print-mode:static;
|
||||
@apply flex flex-col shrink overflow-hidden gap-4 print-mode:static;
|
||||
}
|
||||
|
||||
.openapi-column-preview pre {
|
||||
@@ -192,7 +192,7 @@
|
||||
.openapi-schema-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. */
|
||||
@apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5;
|
||||
@apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5 items-center;
|
||||
}
|
||||
|
||||
.openapi-schema-name .openapi-deprecated {
|
||||
@@ -207,6 +207,10 @@
|
||||
@apply line-through opacity-9;
|
||||
}
|
||||
|
||||
.openapi-schema-discriminator {
|
||||
@apply text-primary-subtle/9 text-[0.813rem] lowercase;
|
||||
}
|
||||
|
||||
.openapi-schema-required {
|
||||
@apply text-warning-subtle text-[0.813rem] lowercase;
|
||||
}
|
||||
@@ -313,10 +317,11 @@
|
||||
}
|
||||
|
||||
.openapi-securities-oauth-flows {
|
||||
@apply flex flex-col gap-2 divide-y divide-tint-subtle;
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.openapi-securities-oauth-content {
|
||||
.openapi-securities-oauth-content,
|
||||
.openapi-securities-scopes {
|
||||
@apply prose *:!prose-sm *:text-tint;
|
||||
}
|
||||
|
||||
@@ -324,7 +329,7 @@
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.openapi-securities-oauth-content ul {
|
||||
.openapi-securities-scopes ul {
|
||||
@apply !my-0;
|
||||
}
|
||||
|
||||
@@ -510,13 +515,21 @@
|
||||
.openapi-panel,
|
||||
.openapi-codesample,
|
||||
.openapi-response-examples {
|
||||
@apply border rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
|
||||
@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;
|
||||
}
|
||||
|
||||
.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-codesample pre,
|
||||
.openapi-response-examples pre {
|
||||
@apply bg-transparent border-none rounded-none shadow-none;
|
||||
@apply bg-transparent border-none rounded-none shrink shadow-none;
|
||||
}
|
||||
|
||||
.openapi-panel-heading {
|
||||
@@ -579,7 +592,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select > button {
|
||||
@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 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 hover:bg-tint-hover transition-all;
|
||||
}
|
||||
|
||||
@@ -613,7 +626,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select-item {
|
||||
@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 text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 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;
|
||||
}
|
||||
|
||||
@@ -638,6 +651,14 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
/* Section Components */
|
||||
.openapi-section {
|
||||
@apply flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.openapi-section-body {
|
||||
@apply flex flex-col shrink overflow-hidden;
|
||||
}
|
||||
|
||||
.openapi-section-header {
|
||||
@apply flex flex-row items-center;
|
||||
}
|
||||
@@ -820,7 +841,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-disclosure-trigger-label {
|
||||
@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;
|
||||
@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;
|
||||
}
|
||||
|
||||
.openapi-disclosure-trigger-label span {
|
||||
|
||||
@@ -95,8 +95,12 @@ export async function RecordCard(
|
||||
}}
|
||||
sizes={[
|
||||
{
|
||||
media: '(max-width: 640px)',
|
||||
width: view.cardSize === 'medium' ? 245 : 376,
|
||||
},
|
||||
{
|
||||
width: view.cardSize === 'medium' ? 490 : 752,
|
||||
},
|
||||
]}
|
||||
resize={context.contentContext?.imageResizer}
|
||||
className={tcls(
|
||||
|
||||
@@ -71,16 +71,18 @@ function processCoverValue(value: DocumentTableImageRecord | string[] | null | u
|
||||
}
|
||||
}
|
||||
|
||||
const imageValue = value as DocumentTableImageRecord | null | undefined;
|
||||
|
||||
// Check if it's the new schema with objectFit
|
||||
if (value && typeof value === 'object' && 'ref' in value && 'objectFit' in value) {
|
||||
if (imageValue && typeof imageValue === 'object' && 'ref' in imageValue) {
|
||||
return {
|
||||
contentRef: value.ref,
|
||||
objectFit: value.objectFit,
|
||||
contentRef: imageValue.ref,
|
||||
objectFit: imageValue.objectFit,
|
||||
};
|
||||
}
|
||||
|
||||
// It's a direct ContentRef
|
||||
return { contentRef: value as ContentRefFile | ContentRefURL };
|
||||
return { contentRef: imageValue || null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { backgroundColorToStyle, textColorToStyle } from './utils/colors';
|
||||
|
||||
export function Text(props: { text: DocumentText }) {
|
||||
const { text } = props;
|
||||
@@ -121,51 +122,3 @@ function Color(props: MarkedLeafProps<DocumentMarkColor>) {
|
||||
</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'],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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,13 +36,9 @@ async function FooterLink(props: { link: CustomizationContentLink; context: GitB
|
||||
const { link, context } = props;
|
||||
const resolved = await resolveContentRef(link.to, context);
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={resolved.href}
|
||||
href={resolved?.href ?? '#'}
|
||||
className={tcls(
|
||||
'font-normal',
|
||||
'text-tint',
|
||||
|
||||
@@ -42,11 +42,11 @@ export function CurrentContentIcon(
|
||||
}
|
||||
: {
|
||||
light: {
|
||||
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=light'),
|
||||
src: linker.toPathInSpace('~gitbook/icon?size=large&theme=light'),
|
||||
size: { width: 256, height: 256 },
|
||||
},
|
||||
dark: {
|
||||
src: linker.toPathInSpace('~gitbook/icon?size=medium&theme=dark'),
|
||||
src: linker.toPathInSpace('~gitbook/icon?size=large&theme=dark'),
|
||||
size: { width: 256, height: 256 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ export function Header(props: {
|
||||
const { context, withTopHeader, withVariants } = props;
|
||||
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 (
|
||||
<header
|
||||
id="site-header"
|
||||
@@ -57,7 +63,7 @@ export function Header(props: {
|
||||
'theme-bold:shadow-tint-12/2'
|
||||
)}
|
||||
>
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
@@ -124,10 +130,7 @@ export function Header(props: {
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) => s.space.language === siteSpace.space.language
|
||||
).length > 1
|
||||
s.object === 'site-section' && s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={!!sections}
|
||||
@@ -147,7 +150,7 @@ export function Header(props: {
|
||||
</div>
|
||||
|
||||
{customization.header.links.length > 0 ||
|
||||
(!sections && withVariants === 'translations') ? (
|
||||
(!withSections && withVariants === 'translations') ? (
|
||||
<HeaderLinks>
|
||||
{customization.header.links.length > 0 ? (
|
||||
<>
|
||||
@@ -167,7 +170,7 @@ export function Header(props: {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{!sections && withVariants === 'translations' ? (
|
||||
{!withSections && withVariants === 'translations' ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
@@ -181,43 +184,19 @@ export function Header(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections ? (
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'w-full',
|
||||
'overflow-x-auto',
|
||||
'no-scrollbar',
|
||||
'-mb-4 pb-4', // Positive padding / negative margin allows the navigation menu indicator to show in a scroll viewƒ
|
||||
!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)}
|
||||
{sections && withSections ? (
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
|
||||
{withVariants === 'translations' ? (
|
||||
<div className="before:contents[] flex self-start py-2 before:mr-4 before:border-tint before:border-l">
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
/>
|
||||
) : null}
|
||||
{withVariants === 'translations' ? (
|
||||
<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>
|
||||
) : null}
|
||||
</SiteSectionTabs>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function HeaderLink(props: {
|
||||
);
|
||||
}
|
||||
|
||||
if (!target || !link.to) {
|
||||
if (!link.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function HeaderLink(props: {
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
isDropdown={false}
|
||||
href={target.href}
|
||||
href={target?.href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export type HeaderLinkNavItemProps = {
|
||||
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
|
||||
headerPreset: CustomizationHeaderPreset;
|
||||
title: string;
|
||||
href: string;
|
||||
href?: string;
|
||||
isDropdown: boolean;
|
||||
} & DropdownButtonProps<HTMLElement>;
|
||||
|
||||
@@ -165,7 +165,7 @@ function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
|
||||
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
href={href ?? '#'}
|
||||
className={getHeaderLinkClassName({ headerPreset })}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
|
||||
@@ -76,7 +76,7 @@ export function TranslationsDropdown(props: {
|
||||
siteSpaces={siteSpaces}
|
||||
variant="blank"
|
||||
className={tcls(
|
||||
'-mx-2 bg-transparent px-2 py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
|
||||
'-mx-2 bg-transparent px-2 md:py-1 lg:max-w-64 max-md:[&_.button-content]:hidden',
|
||||
hasEmojiPrefix
|
||||
? 'md:[&_.button-leading-icon]:hidden' // If the title starts with an emoji, don't show the icon (on desktop)
|
||||
: '',
|
||||
|
||||
@@ -18,17 +18,22 @@ import {
|
||||
ActionViewAsPDF,
|
||||
} from './PageActions';
|
||||
|
||||
interface PageActionsDropdownProps {
|
||||
siteTitle: string;
|
||||
markdownPageURL: string;
|
||||
mcpURL?: string;
|
||||
pdfURL?: string;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
export type PageActionsDropdownURLs = {
|
||||
html: string;
|
||||
markdown: string;
|
||||
mcp?: string;
|
||||
pdf?: string;
|
||||
editOnGit?: {
|
||||
provider: GitSyncState['installationProvider'];
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface PageActionsDropdownProps {
|
||||
siteTitle: string;
|
||||
urls: PageActionsDropdownURLs;
|
||||
className?: string;
|
||||
actions: SiteCustomizationSettings['pageActions'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +80,7 @@ export function PageActionsDropdown(props: PageActionsDropdownProps) {
|
||||
* Return the list of actions to show in the dropdown menu.
|
||||
*/
|
||||
function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNode[] {
|
||||
const { siteTitle, markdownPageURL, mcpURL, actions } = props;
|
||||
const { siteTitle, urls, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
@@ -94,55 +99,45 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionCopyMarkdown
|
||||
isDefaultAction={!assistants.length}
|
||||
markdownPageURL={markdownPageURL}
|
||||
markdownPageURL={urls.markdown}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ActionViewAsMarkdown markdownPageURL={markdownPageURL} type="dropdown-menu-item" />
|
||||
<ActionViewAsMarkdown markdownPageURL={urls.markdown} type="dropdown-menu-item" />
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
actions.externalAI ? (
|
||||
<React.Fragment key="externalAI">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionOpenInLLM
|
||||
provider="chatgpt"
|
||||
url={markdownPageURL}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ActionOpenInLLM
|
||||
provider="claude"
|
||||
url={markdownPageURL}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
<ActionOpenInLLM provider="chatgpt" url={urls.html} type="dropdown-menu-item" />
|
||||
<ActionOpenInLLM provider="claude" url={urls.html} type="dropdown-menu-item" />
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
actions.mcp && mcpURL ? (
|
||||
actions.mcp && urls.mcp ? (
|
||||
<React.Fragment key="mcp">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
<ActionCopyMCPURL mcpURL={mcpURL} type="dropdown-menu-item" />
|
||||
<ActionCopyMCPURL mcpURL={urls.mcp} type="dropdown-menu-item" />
|
||||
<ActionOpenMCP
|
||||
provider="vscode"
|
||||
mcpURL={mcpURL}
|
||||
mcpURL={urls.mcp}
|
||||
siteTitle={siteTitle}
|
||||
type="dropdown-menu-item"
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
|
||||
props.editOnGit || props.pdfURL ? (
|
||||
urls.editOnGit || urls.pdf ? (
|
||||
<React.Fragment key="editOnGit">
|
||||
<DropdownMenuSeparator className="first:hidden" />
|
||||
{props.editOnGit ? (
|
||||
{urls.editOnGit ? (
|
||||
<ActionOpenEditOnGit
|
||||
type="dropdown-menu-item"
|
||||
provider={props.editOnGit.provider}
|
||||
url={props.editOnGit.url}
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
) : null}
|
||||
{props.pdfURL ? (
|
||||
<ActionViewAsPDF url={props.pdfURL} type="dropdown-menu-item" />
|
||||
) : null}
|
||||
{urls.pdf ? <ActionViewAsPDF url={urls.pdf} type="dropdown-menu-item" /> : null}
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
].filter(Boolean);
|
||||
@@ -152,7 +147,7 @@ function getPageDropdownActions(props: PageActionsDropdownProps): React.ReactNod
|
||||
* A default action shown as a quick-access button beside the dropdown menu
|
||||
*/
|
||||
function getPageDefaultAction(props: PageActionsDropdownProps) {
|
||||
const { markdownPageURL, actions } = props;
|
||||
const { urls, actions } = props;
|
||||
const assistants = useAI().assistants.filter(
|
||||
(assistant) => assistant.ui === true && assistant.pageAction
|
||||
);
|
||||
@@ -162,12 +157,12 @@ function getPageDefaultAction(props: PageActionsDropdownProps) {
|
||||
return <ActionOpenAssistant assistant={assistant} type="button" />;
|
||||
}
|
||||
|
||||
if (props.editOnGit) {
|
||||
if (urls.editOnGit) {
|
||||
return (
|
||||
<ActionOpenEditOnGit
|
||||
type="button"
|
||||
provider={props.editOnGit.provider}
|
||||
url={props.editOnGit.url}
|
||||
provider={urls.editOnGit.provider}
|
||||
url={urls.editOnGit.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +171,7 @@ function getPageDefaultAction(props: PageActionsDropdownProps) {
|
||||
return (
|
||||
<ActionCopyMarkdown
|
||||
isDefaultAction={!assistant}
|
||||
markdownPageURL={markdownPageURL}
|
||||
markdownPageURL={urls.markdown}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ export function PageAside(props: {
|
||||
<aside
|
||||
className={tcls(
|
||||
'group/aside',
|
||||
'order-last',
|
||||
'hidden',
|
||||
'pt-8',
|
||||
'pb-4',
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ScrollSectionsList({ sections }: { sections: DocumentSection[] }
|
||||
'opacity-8',
|
||||
'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-tint',
|
||||
],
|
||||
|
||||
@@ -51,10 +51,10 @@ export function PageBody(props: {
|
||||
<main
|
||||
className={tcls(
|
||||
'relative min-w-0 flex-1',
|
||||
'mx-auto max-w-screen-2xl py-8',
|
||||
'max-w-screen-2xl py-8',
|
||||
// Allow words to break if they are too long.
|
||||
'break-anywhere',
|
||||
pageWidthWide ? 'page-width-wide 2xl:px-8' : 'page-width-default',
|
||||
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
|
||||
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
|
||||
)}
|
||||
@@ -81,7 +81,10 @@ export function PageBody(props: {
|
||||
blockStyle="page-api-block:ml-0"
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: context,
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
withLinkPreviews,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,10 @@ import { type RevisionPageDocument, SiteVisibility } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import urlJoin from 'url-join';
|
||||
import { getPDFURLSearchParams } from '../PDF';
|
||||
import { PageActionsDropdown } from '../PageActions/PageActionsDropdown';
|
||||
import {
|
||||
PageActionsDropdown,
|
||||
type PageActionsDropdownURLs,
|
||||
} from '../PageActions/PageActionsDropdown';
|
||||
import { PageIcon } from '../PageIcon';
|
||||
import { StyledLink } from '../primitives';
|
||||
|
||||
@@ -40,35 +43,7 @@ export async function PageHeader(props: {
|
||||
// Show page actions if *any* of the actions are enabled
|
||||
<PageActionsDropdown
|
||||
siteTitle={context.site.title}
|
||||
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
|
||||
}
|
||||
urls={getPageActionsURLs(context, page)}
|
||||
actions={context.customization.pageActions}
|
||||
className={tcls(
|
||||
'float-right ml-4 xl:max-2xl:page-api-block:mr-62',
|
||||
@@ -146,3 +121,38 @@ export async function PageHeader(props: {
|
||||
</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 type { TranslationLanguage } from '@/intl/translations';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { HashProvider } from '../hooks';
|
||||
import { NavigationStatusProvider } from '../hooks';
|
||||
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
|
||||
|
||||
/**
|
||||
@@ -20,9 +20,9 @@ export function RootLayoutClientContexts(props: {
|
||||
return (
|
||||
<TranslateContext.Provider value={language}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<HashProvider>
|
||||
<NavigationStatusProvider>
|
||||
<LoadingStateProvider>{children}</LoadingStateProvider>
|
||||
</HashProvider>
|
||||
</NavigationStatusProvider>
|
||||
</TooltipProvider>
|
||||
</TranslateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
::-webkit-scrollbar {
|
||||
background: transparent;
|
||||
max-width: 8px;
|
||||
max-height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useRef } from 'react';
|
||||
@@ -16,6 +17,8 @@ import { SearchInput } from './SearchInput';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { useSearch } from './useSearch';
|
||||
import { useSearchResults } from './useSearchResults';
|
||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||
|
||||
interface SearchContainerProps {
|
||||
/** The current site space id. */
|
||||
@@ -160,19 +163,6 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.moveUp();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.moveDown();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
|
||||
@@ -186,10 +176,38 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
const normalizedQuery = state?.query?.trim() ?? '';
|
||||
const normalizedAsk = state?.ask?.trim() ?? '';
|
||||
|
||||
const showAsk = withSearchAI && normalizedAsk; // withSearchAI && normalizedAsk;
|
||||
const showAsk = withSearchAI && normalizedAsk;
|
||||
|
||||
const visible = viewport === 'desktop' ? !isMobile : viewport === 'mobile' ? isMobile : true;
|
||||
|
||||
const searchResultsId = `search-results-${React.useId()}`;
|
||||
const { results, fetching } = useSearchResults({
|
||||
disabled: !(state?.query || withAI),
|
||||
query: normalizedQuery,
|
||||
siteSpaceId,
|
||||
siteSpaceIds,
|
||||
scope: state?.scope ?? 'default',
|
||||
withAI: withAI,
|
||||
});
|
||||
const searchValue = state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? '';
|
||||
|
||||
const { cursor, moveBy: moveCursorBy } = useSearchResultsCursor({
|
||||
query: normalizedQuery,
|
||||
results,
|
||||
});
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveCursorBy(-1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveCursorBy(1);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
resultsRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchAskProvider value={searchAsk}>
|
||||
<Popover
|
||||
@@ -210,9 +228,10 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
scope={state?.scope ?? 'default'}
|
||||
siteSpaceId={siteSpaceId}
|
||||
siteSpaceIds={siteSpaceIds}
|
||||
id={searchResultsId}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
@@ -220,22 +239,21 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
) : null
|
||||
}
|
||||
rootProps={{
|
||||
open: visible && (state?.open ?? false),
|
||||
onOpenChange: (open) => {
|
||||
open ? onOpen() : onClose();
|
||||
},
|
||||
modal: isMobile,
|
||||
open: Boolean(visible && (state?.open ?? false)),
|
||||
modal: false,
|
||||
}}
|
||||
contentProps={{
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className:
|
||||
'@container bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
'@container overflow-y-scroll bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
onInteractOutside: (event) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
sideOffset: 8,
|
||||
collisionPadding: {
|
||||
@@ -252,14 +270,23 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
>
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
value={state?.query ?? (withSearchAI || !withAI ? state?.ask : null) ?? ''}
|
||||
value={searchValue}
|
||||
onFocus={onOpen}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
withAI={withSearchAI}
|
||||
isOpen={state?.open ?? false}
|
||||
className={className}
|
||||
/>
|
||||
aria-controls={searchResultsId}
|
||||
aria-activedescendant={
|
||||
cursor !== null ? `${searchResultsId}-${cursor}` : undefined
|
||||
}
|
||||
>
|
||||
<LiveResultsAnnouncer
|
||||
count={results.length}
|
||||
showing={Boolean(searchValue) && !fetching}
|
||||
/>
|
||||
</SearchInput>
|
||||
</Popover>
|
||||
{assistants
|
||||
.filter((assistant) => assistant.ui === true)
|
||||
@@ -277,3 +304,21 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
</SearchAskProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Screen reader announcement for search results.
|
||||
* Without it there is no feedback for screen reader users when a search returns no results.
|
||||
*/
|
||||
function LiveResultsAnnouncer(props: { count: number; showing: boolean }) {
|
||||
const { count, showing } = props;
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<div className="sr-only" aria-live="assertive" role="alert" aria-relevant="all">
|
||||
{showing
|
||||
? count > 0
|
||||
? t(language, 'search_results_count', count)
|
||||
: t(language, 'search_no_results')
|
||||
: ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user