mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 23:25:16 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8183c786c | |||
| 1d1f0a7c6a | |||
| 590729cce5 | |||
| b554d7d13f | |||
| 9568b8a7c8 | |||
| 683bb8f634 | |||
| 7d6a31627f | |||
| c31e5648f5 | |||
| 0c01832684 | |||
| 6b1c9fef86 | |||
| cc976995cc | |||
| ebbb476589 | |||
| 89655cb428 | |||
| 3676d64bdc | |||
| f682df1e01 | |||
| 6c9893945d | |||
| a2fb627ae1 | |||
| f9f80117f0 | |||
| 63bf4dbb16 | |||
| b3fa503aff | |||
| 0ae7cfd790 | |||
| 8fdc5c89dc | |||
| 758b34d46b | |||
| 1b0613e482 | |||
| 3164f1a99d | |||
| 6b588af07d | |||
| d436b04ff4 | |||
| d159788d0b | |||
| 3355960d95 | |||
| c32d4494f2 | |||
| 1dcf4df51f | |||
| 3c0e49d56b | |||
| c35d0e8954 | |||
| c0d73d2f76 | |||
| 454175ab86 | |||
| 6feb8fcd6a | |||
| b8d3cb7ee5 | |||
| 8ce73229c3 | |||
| 8eae6e970b | |||
| a48b886dfd | |||
| 17de0838c5 | |||
| baf3496980 | |||
| ae33c09aa7 | |||
| 7ff69c3b72 | |||
| 6142d6bf80 | |||
| 75543b140f | |||
| 511f1cfbe5 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix webframe height issue
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix search container shadow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Revert contentref anchor title
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Move the "Ask <query>" to the bottom of search results if query is not a question
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve AI Search branding
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add alt text support to card covers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve ancestor label in references
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Tweak language dropdown styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix ToggeableLinkItem display
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix icon resolution for page group
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add confirmation to "Copy MCP URL" page action
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Update search scope UI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI path display
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add OpenAPI servers selection
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix opacity of ScrollContainer buttons on bold themes
|
||||
@@ -54,10 +54,9 @@ After forking this repository, you'll want to [create a branch](https://docs.git
|
||||
#### 3. Install dependencies and run the project locally
|
||||
|
||||
##### Prerequisites:
|
||||
- Node.js (Version: >=20.6)
|
||||
- Use `nvm` for easy Node management
|
||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||
|
||||
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
|
||||
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
|
||||
|
||||
##### Setup steps:
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- changeset-release/main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -41,7 +42,7 @@ jobs:
|
||||
name: Build (Open Source)
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ''
|
||||
BUN_NPM_TOKEN: ''
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
|
||||
@@ -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,8 +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 }}
|
||||
NPM_CONFIG_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" }
|
||||
|
||||
+22
-5
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": "^22.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.29.7",
|
||||
"turbo": "^2.5.8",
|
||||
"vercel": "^39.4.2"
|
||||
},
|
||||
"packageManager": "bun@1.2.15",
|
||||
"packageManager": "bun@1.3.0",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"esbuild": "0.24.2"
|
||||
},
|
||||
"private": true,
|
||||
@@ -35,9 +40,21 @@
|
||||
"workspaces": {
|
||||
"packages": ["packages/*"],
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.143.2",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.146.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"assert-never": "^1.4.0",
|
||||
"bidc": "^0.0.2",
|
||||
"tsdown": "^0.15.6"
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsdown": "^0.15.6",
|
||||
"typescript": "^5.5.3",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @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
|
||||
|
||||
@@ -8,17 +8,21 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/cache-tags
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,24 +4,26 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.3.2",
|
||||
"sideEffects": false,
|
||||
"version": "0.3.3",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"assert-never": "^1.2.1"
|
||||
"assert-never": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"],
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/colors
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,20 +4,22 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.4.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.4.2",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w"
|
||||
"dev": "bun run build -- --watch ./src"
|
||||
},
|
||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"],
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @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
|
||||
|
||||
@@ -12,19 +12,21 @@
|
||||
"default": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.3",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:",
|
||||
"bidc": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0"
|
||||
"react": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "^5.5.3",
|
||||
"react": "^19.0.0"
|
||||
"typescript": "catalog:",
|
||||
"react": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run build-lib && bun run build-standalone",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,12 @@ import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['src/index.ts', 'src/react/index.ts'],
|
||||
dts: true,
|
||||
format: ['esm'],
|
||||
entry: 'src/index.ts',
|
||||
outDir: 'dist',
|
||||
},
|
||||
{
|
||||
entry: 'src/react/index.ts',
|
||||
outDir: 'dist/react',
|
||||
unbundle: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/emoji-codepoints
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"name": "@gitbook/emoji-codepoints",
|
||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||
"version": "0.2.1",
|
||||
"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",
|
||||
"clean": "rm -rf ./dist"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/expr
|
||||
|
||||
## 1.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
{
|
||||
"name": "@gitbook/expr",
|
||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||
"version": "1.2.2",
|
||||
"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": "catalog:",
|
||||
"@tsconfig/strictest": "catalog:",
|
||||
"@tsconfig/node20": "catalog:",
|
||||
"@types/escodegen": "^0.0.10",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@babel/types": "^7.26.0",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/escodegen": "^0.0.10"
|
||||
"bun-types": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/fonts
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -4,27 +4,29 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"sideEffects": false,
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"google-font-metadata": "^6.0.3",
|
||||
"typescript": "^5.5.3"
|
||||
"bun-types": "catalog:",
|
||||
"google-font-metadata": "^6.0.7",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./bin/generate.js",
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||
"unit": "bun test"
|
||||
},
|
||||
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
||||
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
|
||||
@@ -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,25 @@
|
||||
# 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
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
@@ -228,6 +229,58 @@ const testCases: TestsCase[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Expandable TOC navigation',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
|
||||
// Verify "Navigation" link is not visible initially
|
||||
const navigationLink = page.getByRole('link', { name: 'Navigation' });
|
||||
await expect(navigationLink).not.toBeVisible();
|
||||
|
||||
// Find and click the chevron element that is next to "Editor" in the TOC
|
||||
// It is a span inside the link
|
||||
const editorChevron = page
|
||||
.getByRole('link', { name: 'Editor' })
|
||||
.locator('span');
|
||||
await editorChevron.click();
|
||||
|
||||
// Verify "Navigation" link becomes visible after expansion
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Expandable nested TOC navigation',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
|
||||
// Verify "Spaces" link is not visible initially
|
||||
const navigationLink = page.getByRole('link', { name: 'Spaces' });
|
||||
await expect(navigationLink).not.toBeVisible();
|
||||
|
||||
// Find and click the chevron element that is next to "Editor" in the TOC
|
||||
// It is a span inside the link
|
||||
const editorChevron = page
|
||||
.getByRole('link', { name: 'Editor' })
|
||||
.locator('span');
|
||||
await editorChevron.click();
|
||||
|
||||
// At this stage the link should still not be visible
|
||||
await expect(navigationLink).not.toBeVisible();
|
||||
|
||||
// Then we click 'Content Structure' chevron to expand further
|
||||
const contentStructureChevron = page
|
||||
.getByRole('link', { name: 'Content Structure' })
|
||||
.locator('span');
|
||||
await contentStructureChevron.click();
|
||||
|
||||
// Verify "Spaces" link becomes visible after expansion
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
@@ -727,7 +780,10 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/integrations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
|
||||
const mermaidIframe = page
|
||||
.locator('iframe[title*="mermaid"]')
|
||||
.first()
|
||||
.contentFrame();
|
||||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||
},
|
||||
@@ -851,7 +907,10 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With cover',
|
||||
url: 'page-options/page-with-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover for dark mode',
|
||||
@@ -866,12 +925,18 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With hero cover',
|
||||
url: 'page-options/page-with-hero-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover and no TOC',
|
||||
url: 'page-options/page-with-cover-and-no-toc',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
screenshot: {
|
||||
waitForTOCScrolling: false,
|
||||
},
|
||||
@@ -1454,10 +1519,7 @@ const testCases: TestsCase[] = [
|
||||
locale,
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,7 +143,10 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
||||
|
||||
export async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toBeVisible({
|
||||
// Cookies dialog may take some times to appear
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
@@ -151,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page) {
|
||||
// Wait for cover images to exist (not the shimmer placeholder)
|
||||
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform test cases into Playwright tests and run it.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,8 @@ export default {
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
incrementalCache: () =>
|
||||
import('./openNext/incrementalCache/server').then((m) => m.default),
|
||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||
},
|
||||
},
|
||||
@@ -18,7 +19,8 @@ export default {
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
incrementalCache: () =>
|
||||
import('./openNext/incrementalCache/middleware').then((m) => m.default),
|
||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"main": "default.js",
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"main": "do.js",
|
||||
"name": "gitbook-open-v2-do",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"main": "middleware.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
|
||||
+1
-12
@@ -7,8 +7,6 @@ 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';
|
||||
@@ -23,7 +21,7 @@ export type KeyOptions = {
|
||||
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
|
||||
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
|
||||
*/
|
||||
class GitbookIncrementalCache implements IncrementalCache {
|
||||
export class GitbookIncrementalCache implements IncrementalCache {
|
||||
name = 'GitbookIncrementalCache';
|
||||
|
||||
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||
@@ -137,12 +135,3 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
bypassTagCacheOnCacheHit: false,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.19.1",
|
||||
"version": "0.19.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20251011.0",
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/embed": "workspace:*",
|
||||
"@gitbook/fonts": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -16,7 +18,8 @@
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
||||
"@opennextjs/cloudflare": "^1.9.2",
|
||||
"@opennextjs/aws": "^3.8.5",
|
||||
"@opennextjs/cloudflare": "^1.11.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
@@ -28,19 +31,15 @@
|
||||
"@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",
|
||||
"image-size": "^2.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mcp-handler": "^1.0.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
@@ -49,7 +48,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.12",
|
||||
"motion": "^12.23.24",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
@@ -58,6 +57,8 @@
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.3",
|
||||
"quick-lru": "^7.0.1",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -73,7 +74,7 @@
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3"
|
||||
@@ -81,6 +82,7 @@
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
@@ -89,9 +91,10 @@
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/rison": "^0.0.9",
|
||||
"bun-types": "catalog:",
|
||||
"deepmerge": "^4.3.1",
|
||||
"env-cmd": "^10.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -99,8 +102,9 @@
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vercel": "^39.3.0"
|
||||
"typescript": "catalog:",
|
||||
"vercel": "^39.3.0",
|
||||
"wrangler": "^4.43.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "./scripts/generate.sh",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSearch } from '../Search/useSearch';
|
||||
|
||||
@@ -104,16 +104,7 @@ export function useAI(): AIContext {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<Icon icon="search" className="size-4" />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="absolute top-[2.5px] left-[2.6px] size-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
icon: <AISearchIcon />,
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, IconStyle } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
@@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatIcon = ({
|
||||
export function AIChatIcon({
|
||||
className = 'size-4',
|
||||
size,
|
||||
trademark = true,
|
||||
state = 'default',
|
||||
...props
|
||||
}: AIChatIconProps) => {
|
||||
}: AIChatIconProps) {
|
||||
if (!trademark) {
|
||||
return (
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
{...props}
|
||||
className={tcls(
|
||||
className,
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
@@ -163,4 +164,36 @@ export const AIChatIcon = ({
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function AISearchIcon({
|
||||
className = 'size-4',
|
||||
state = 'default',
|
||||
}: Pick<AIChatIconProps, 'className' | 'state'>) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
state === 'intro' && 'animate-[fadeIn_1s_both,orbit_1s_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[fadeIn_1s_both,orbit_1s_ease-out,orbit_2s_1s_infinite_forwards_linear]'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<Icon icon="search" className={className} />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'absolute top-[15.7%] left-[15.6%] size-[50%]',
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: '',
|
||||
state === 'intro'
|
||||
? 'animate-[spin_2s_.5s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
export function AIChatMessages(props: {
|
||||
chat: AIChatState;
|
||||
chatController: AIChatController;
|
||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
||||
lastUserMessageRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { chat, chatController, lastUserMessageRef } = props;
|
||||
|
||||
@@ -81,11 +81,19 @@ export function AIChatMessages(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function HoldMessage() {
|
||||
export function HoldMessage({
|
||||
breakLines = false,
|
||||
className = '',
|
||||
}: { breakLines?: boolean; className?: string }) {
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
|
||||
<div
|
||||
className={tcls(
|
||||
'animate-[heightIn_500ms_4500ms_ease_both] overflow-hidden py-2 text-tint-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{tString(language, 'ai_chat_hold_message_1')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
@@ -99,6 +107,7 @@ function HoldMessage() {
|
||||
{word}{' '}
|
||||
</span>
|
||||
))}
|
||||
{breakLines ? <br /> : null}
|
||||
{tString(language, 'ai_chat_hold_message_2')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
|
||||
@@ -167,10 +167,10 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
icon="comment"
|
||||
/>
|
||||
|
||||
{/* Open production site */}
|
||||
{/* Open published/live site */}
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open production site"
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
@@ -249,7 +249,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
||||
/>
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open production site"
|
||||
title="Open live site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
|
||||
@@ -35,6 +35,7 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
// @ts-expect-error wrong type for ref
|
||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||
|
||||
// Close arc menu on scroll
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
import React from 'react';
|
||||
import React, { isValidElement } from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
@@ -291,6 +291,10 @@ function ToolbarButtonWrapper(props: {
|
||||
x: springX,
|
||||
};
|
||||
|
||||
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return React.cloneElement(child, {
|
||||
motionValues,
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ const calculateSpacing = (
|
||||
|
||||
export function useMagnificationEffect(props: {
|
||||
childrenCount: number;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
config?: MagnificationConfig;
|
||||
}) {
|
||||
const { childrenCount, containerRef, config } = props;
|
||||
|
||||
@@ -51,11 +51,13 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
|
||||
* Ideally we'd rely on the block type to determine if it can be full width, but
|
||||
* the block's `fullWidth` property does not differentiate between `undefined` and `false`.
|
||||
* So instead we hardcode a list of blocks that can be full width. */
|
||||
const FULL_WIDTH_BLOCKS = [
|
||||
const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
|
||||
'table',
|
||||
'tabs',
|
||||
'integration',
|
||||
'openapi',
|
||||
'openapi-operation',
|
||||
'openapi-schemas',
|
||||
'openapi-webhook',
|
||||
'images',
|
||||
'embed',
|
||||
'columns',
|
||||
@@ -64,6 +66,8 @@ const FULL_WIDTH_BLOCKS = [
|
||||
'hint',
|
||||
];
|
||||
|
||||
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
|
||||
|
||||
/**
|
||||
* Renders a list of blocks without a wrapper element.
|
||||
*/
|
||||
@@ -85,10 +89,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
||||
key={node.key || `${node.type}-${index}`}
|
||||
block={node}
|
||||
style={[
|
||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6 print:break-inside-avoid',
|
||||
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
|
||||
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
|
||||
blockStyle,
|
||||
]}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||
@@ -18,6 +21,8 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
};
|
||||
|
||||
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||
|
||||
/**
|
||||
* Render a code-block client-side by loading the highlighter asynchronously.
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
@@ -113,13 +118,82 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
setLines(null);
|
||||
}, [isInViewport, block, inlines, evaluateInlineExpression]);
|
||||
|
||||
return (
|
||||
const expandable = block.data.expandable;
|
||||
|
||||
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
|
||||
const collapsedLineCount =
|
||||
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
|
||||
const isExpandable = Boolean(expandable && numberOfLinesOfCode > collapsedLineCount);
|
||||
|
||||
const codeBlockBodyId = useId();
|
||||
|
||||
const renderer = (
|
||||
<CodeBlockRenderer
|
||||
ref={blockRef}
|
||||
aria-busy={highlighting}
|
||||
block={block}
|
||||
style={style}
|
||||
lines={lines ?? plainLines}
|
||||
id={codeBlockBodyId}
|
||||
/>
|
||||
);
|
||||
|
||||
return isExpandable ? (
|
||||
<CodeBlockExpandable
|
||||
lines={lines ?? plainLines}
|
||||
controls={codeBlockBodyId}
|
||||
collapsedLineCount={collapsedLineCount}
|
||||
>
|
||||
{renderer}
|
||||
</CodeBlockExpandable>
|
||||
) : (
|
||||
renderer
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockExpandable(props: {
|
||||
children: React.ReactNode;
|
||||
lines: HighlightLine[];
|
||||
collapsedLineCount: number;
|
||||
controls?: string;
|
||||
}) {
|
||||
const { children, controls, lines = [], collapsedLineCount } = props;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<div className="group/codeblock-expandable relative">
|
||||
<div
|
||||
className={tcls(
|
||||
isExpanded
|
||||
? '[&_pre]:after:opacity-0'
|
||||
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
|
||||
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--line-count': collapsedLineCount,
|
||||
'--line-height': '1.25rem',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
|
||||
<Button
|
||||
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
size="xsmall"
|
||||
variant="blank"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={controls}
|
||||
>
|
||||
{isExpanded
|
||||
? t(language, 'code_block_expanded')
|
||||
: t(language, 'code_block_collapsed', lines.length)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
return { inline, body };
|
||||
});
|
||||
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression) {
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
|
||||
// In v2, we render the code block server-side
|
||||
const lines = await highlight(block, richInlines);
|
||||
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight';
|
||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
lines: HighlightLine[];
|
||||
'aria-busy'?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
) {
|
||||
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
||||
|
||||
const id = useId();
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
|
||||
const id = useId();
|
||||
const codeId = props.id || id;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
codeId={codeId}
|
||||
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
@@ -53,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
id={codeId}
|
||||
className={tcls(
|
||||
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap'
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
|
||||
alt="Drawing"
|
||||
sizes={imageBlockSizes}
|
||||
zoom
|
||||
loading="lazy"
|
||||
/>
|
||||
</Caption>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
sources={{ light: { src: embed.icon } }}
|
||||
sizes={[{ width: 20 }]}
|
||||
resize={context.contentContext.imageResizer}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -17,29 +17,34 @@ export function Details(props: {
|
||||
}) {
|
||||
const { children, id, className } = props;
|
||||
|
||||
const detailsRef = React.useRef<HTMLDetailsElement>(null);
|
||||
const ref = React.useRef<HTMLDetailsElement>(null);
|
||||
|
||||
const [openFromHash, setOpenFromHash] = React.useState(false);
|
||||
|
||||
const hash = useHash();
|
||||
|
||||
/**
|
||||
* Open the details element if the url hash refers to the id of the details element
|
||||
* or the id of some element contained within the details element.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!hash || !detailsRef.current) {
|
||||
if (!hash || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hash === id) {
|
||||
setOpenFromHash(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
|
||||
const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
|
||||
setOpenFromHash(isOpen);
|
||||
}, [hash, id]);
|
||||
|
||||
return (
|
||||
<details
|
||||
ref={detailsRef}
|
||||
ref={ref}
|
||||
id={id}
|
||||
open={props.open || openFromHash}
|
||||
className={tcls(
|
||||
|
||||
@@ -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',
|
||||
@@ -47,7 +51,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
className={tcls(
|
||||
'inline-block',
|
||||
'size-3',
|
||||
'mr-2',
|
||||
'mr-3',
|
||||
'mb-1',
|
||||
'transition-transform',
|
||||
'shrink-0',
|
||||
@@ -92,7 +96,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
context={context}
|
||||
style={['px-10', 'pb-5', 'space-y-4']}
|
||||
style="space-y-4 px-10 pb-5"
|
||||
/>
|
||||
</Details>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
|
||||
import { t } from '@/intl/translate';
|
||||
import { getSimplifiedContentType } from '@/lib/files';
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { DownloadButton } from '../primitives/DownloadButton';
|
||||
import type { BlockProps } from './Block';
|
||||
import { Caption } from './Caption';
|
||||
import { FileIcon } from './FileIcon';
|
||||
@@ -12,67 +14,70 @@ import { FileIcon } from './FileIcon';
|
||||
export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
const { block, context } = props;
|
||||
|
||||
const contentRef = context.contentContext
|
||||
? await resolveContentRef(block.data.ref, context.contentContext)
|
||||
: null;
|
||||
if (!context.contentContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
|
||||
const file = contentRef?.file;
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const language = getSpaceLanguage(context.contentContext);
|
||||
const contentType = getSimplifiedContentType(file.contentType);
|
||||
const insights = {
|
||||
type: 'link_click' as const,
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Caption {...props} withBorder>
|
||||
<Link
|
||||
href={file.downloadURL}
|
||||
download={file.name}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
}}
|
||||
className={tcls('group/file', 'flex', 'flex-row', 'items-center', 'px-5', 'py-3')}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'min-w-14',
|
||||
'mr-5',
|
||||
'pr-5',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
'border-r',
|
||||
'border-tint-subtle'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<FileIcon
|
||||
contentType={contentType}
|
||||
className={tcls('size-5', 'text-primary')}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-5 px-5 py-3">
|
||||
<div className="flex min-w-14 flex-col items-center gap-1 border-tint-subtle border-r pr-5">
|
||||
<FileIcon contentType={contentType} className="size-5 text-primary" />
|
||||
<div className="text-hint text-xs">{getHumanFileSize(file.size)}</div>
|
||||
</div>
|
||||
<div className="min-w-24 flex-1">
|
||||
<div className="text-base">
|
||||
<Link
|
||||
href={file.downloadURL}
|
||||
target="_blank"
|
||||
insights={insights}
|
||||
className="hover:underline"
|
||||
>
|
||||
{file.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'text-tint',
|
||||
'group-hover/file:text-tint-strong'
|
||||
)}
|
||||
<div className="text-sm opacity-9 dark:opacity-8">{contentType}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<DownloadButton
|
||||
icon="download"
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
downloadUrl={file.downloadURL}
|
||||
filename={file.name}
|
||||
insights={insights}
|
||||
>
|
||||
{getHumanFileSize(file.size)}
|
||||
</div>
|
||||
{t(language, 'download')}
|
||||
</DownloadButton>
|
||||
<Button
|
||||
icon="arrow-up-right-from-square"
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
href={file.downloadURL}
|
||||
target="_blank"
|
||||
insights={insights}
|
||||
>
|
||||
{t(language, 'open')}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className={tcls('text-base')}>{file.name}</div>
|
||||
<div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>
|
||||
{contentType}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Caption>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function FileIcon(props: { contentType: SimplifiedFileType | null; classN
|
||||
const { contentType, className } = props;
|
||||
|
||||
switch (contentType) {
|
||||
case 'pdf':
|
||||
case 'PDF':
|
||||
return <Icon icon="file-pdf" className={className} />;
|
||||
case 'image':
|
||||
return <Icon icon="file-image" className={className} />;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Link } from '../primitives';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,8 @@ export function HashLinkButton(props: {
|
||||
'h-[1em]',
|
||||
'border-0',
|
||||
'opacity-0',
|
||||
'site-background',
|
||||
'rounded',
|
||||
'group-hover/hash:opacity-[0]',
|
||||
'group-focus/hash:opacity-[0]',
|
||||
'md:group-hover/hash:opacity-[1]',
|
||||
@@ -35,10 +38,10 @@ export function HashLinkButton(props: {
|
||||
className
|
||||
)}
|
||||
>
|
||||
<a
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
aria-label={label}
|
||||
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
|
||||
className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
|
||||
>
|
||||
<Icon
|
||||
icon="hashtag"
|
||||
@@ -52,7 +55,7 @@ export function HashLinkButton(props: {
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,34 +19,42 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
className={tcls(
|
||||
style,
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-3',
|
||||
'w-full',
|
||||
align === 'center' && 'justify-center',
|
||||
align === 'right' && 'justify-end',
|
||||
align === 'left' && 'justify-start',
|
||||
hasMultipleImages && ['grid', 'grid-flow-col'],
|
||||
withFrame && [
|
||||
'rounded-2xl',
|
||||
'border',
|
||||
'border-[rgb(234,235,238)]',
|
||||
'dark:border-[rgb(45,50,58)]',
|
||||
'relative',
|
||||
'overflow-hidden',
|
||||
]
|
||||
align === 'left' && 'justify-start'
|
||||
)}
|
||||
>
|
||||
{block.nodes.map((node: any, _i: number) => (
|
||||
<ImageBlock
|
||||
key={node.key}
|
||||
block={node}
|
||||
document={document}
|
||||
style={[]}
|
||||
siblings={block.nodes.length}
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
withFrame={withFrame}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-3',
|
||||
hasMultipleImages && ['grid', 'grid-flow-col'],
|
||||
withFrame && [
|
||||
'rounded-2xl',
|
||||
'border',
|
||||
'border-[rgb(234,235,238)]',
|
||||
'dark:border-[rgb(45,50,58)]',
|
||||
'relative',
|
||||
'overflow-hidden',
|
||||
'w-fit',
|
||||
]
|
||||
)}
|
||||
>
|
||||
{block.nodes.map((node: any, _i: number) => (
|
||||
<ImageBlock
|
||||
key={node.key}
|
||||
block={node}
|
||||
document={document}
|
||||
style={[]}
|
||||
siblings={block.nodes.length}
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
withFrame={withFrame}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,8 +143,7 @@ async function ImageBlock(props: {
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
|
||||
preload
|
||||
loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
|
||||
zoom
|
||||
inlineStyle={{
|
||||
maxWidth: '100%',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api';
|
||||
import type { DocumentInlineIcon } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
@@ -7,16 +7,11 @@ 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;
|
||||
const { color, icon } = inline.data;
|
||||
|
||||
return (
|
||||
<Icon
|
||||
icon={icon}
|
||||
icon={icon as IconName}
|
||||
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -49,8 +49,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority="lazy"
|
||||
preload
|
||||
loading="lazy"
|
||||
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
|
||||
inline
|
||||
zoom={!isInLink}
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
'use client';
|
||||
import dynamic from 'next/dynamic';
|
||||
import React from 'react';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Fragment } from 'react';
|
||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
|
||||
|
||||
const LoadingValueContext = React.createContext<React.ReactNode>(null);
|
||||
|
||||
// To avoid polluting the RSC payload with the tooltip implementation,
|
||||
// we lazily load it on the client side. This way, the tooltip is only loaded
|
||||
// when the user interacts with the link, and it doesn't block the initial render.
|
||||
|
||||
const InlineLinkTooltipImpl = dynamic(
|
||||
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
|
||||
{
|
||||
// Disable server-side rendering for this component, it's only
|
||||
// visible on user interaction.
|
||||
ssr: false,
|
||||
loading: () => {
|
||||
// The fallback should be the children (the content of the link),
|
||||
// but as next/dynamic is aiming for feature parity with React.lazy,
|
||||
// it doesn't support passing children to the loading component.
|
||||
// https://github.com/vercel/next.js/issues/7906
|
||||
const children = React.useContext(LoadingValueContext);
|
||||
return <>{children}</>;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
|
||||
* and polluting the RSC payload.
|
||||
*
|
||||
* The link text and href have already been rendered on the server for good SEO,
|
||||
* so we can be as lazy as possible with the tooltip.
|
||||
*/
|
||||
export function InlineLinkTooltip(props: {
|
||||
isSamePage: boolean;
|
||||
isExternal: boolean;
|
||||
@@ -45,28 +17,79 @@ export function InlineLinkTooltip(props: {
|
||||
openInNewTabLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { children, ...rest } = props;
|
||||
const [shouldLoad, setShouldLoad] = React.useState(false);
|
||||
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
|
||||
|
||||
// Once the browser is idle, we set shouldLoad to true.
|
||||
// NOTE: to be slightly more performant, we could load when a link is hovered.
|
||||
// But I found this was too much of a delay for the tooltip to appear.
|
||||
// Loading on idle is a good compromise, as it allows the initial render to be fast,
|
||||
// while still loading the tooltip in the background and not polluting the RSC payload.
|
||||
React.useEffect(() => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true));
|
||||
} else {
|
||||
// fallback for old browsers
|
||||
setTimeout(() => setShouldLoad(true), 2000);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<HoverCardRoot>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCard className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex flex-col">
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const Tag = crumb.href ? StyledLink : 'div';
|
||||
|
||||
return shouldLoad ? (
|
||||
<LoadingValueContext.Provider value={children}>
|
||||
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl>
|
||||
</LoadingValueContext.Provider>
|
||||
) : (
|
||||
children
|
||||
return (
|
||||
<Fragment key={crumb.label}>
|
||||
{index !== 0 ? (
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 text-tint-subtle"
|
||||
/>
|
||||
) : null}
|
||||
<Tag
|
||||
className={tcls(
|
||||
'flex gap-1',
|
||||
crumb.href &&
|
||||
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||
)}
|
||||
href={crumb.href ?? '#'}
|
||||
>
|
||||
{crumb.icon ? (
|
||||
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||
{crumb.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{crumb.label}
|
||||
</Tag>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-2 leading-snug',
|
||||
isExternal && 'wrap-anywhere text-sm'
|
||||
)}
|
||||
>
|
||||
{target.icon ? (
|
||||
<div className="mt-1 text-tint-subtle empty:hidden">
|
||||
{target.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h5 className="font-semibold">{target.text}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{!isSamePage && target.href ? (
|
||||
<Button
|
||||
className={tcls(
|
||||
'-mx-2 -my-2 ml-auto',
|
||||
breadcrumbs?.length === 0 ? 'place-self-center' : null
|
||||
)}
|
||||
variant="blank"
|
||||
href={target.href}
|
||||
target="_blank"
|
||||
label={openInNewTabLabel}
|
||||
size="small"
|
||||
icon="arrow-up-right-from-square"
|
||||
iconOnly={true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
|
||||
</HoverCard>
|
||||
</HoverCardRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Fragment } from 'react';
|
||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
|
||||
|
||||
export function InlineLinkTooltipImpl(props: {
|
||||
isSamePage: boolean;
|
||||
isExternal: boolean;
|
||||
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
|
||||
target: {
|
||||
href: string;
|
||||
text: string;
|
||||
subText?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
openInNewTabLabel: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
|
||||
|
||||
return (
|
||||
<HoverCardRoot>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCard className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex flex-col">
|
||||
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||
{breadcrumbs.map((crumb, index) => {
|
||||
const Tag = crumb.href ? StyledLink : 'div';
|
||||
|
||||
return (
|
||||
<Fragment key={crumb.label}>
|
||||
{index !== 0 ? (
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className="size-3 text-tint-subtle"
|
||||
/>
|
||||
) : null}
|
||||
<Tag
|
||||
className={tcls(
|
||||
'flex gap-1',
|
||||
crumb.href &&
|
||||
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||
)}
|
||||
href={crumb.href ?? '#'}
|
||||
>
|
||||
{crumb.icon ? (
|
||||
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||
{crumb.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{crumb.label}
|
||||
</Tag>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex gap-2 leading-snug',
|
||||
isExternal && 'wrap-anywhere text-sm'
|
||||
)}
|
||||
>
|
||||
{target.icon ? (
|
||||
<div className="mt-1 text-tint-subtle empty:hidden">
|
||||
{target.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h5 className="font-semibold">{target.text}</h5>
|
||||
</div>
|
||||
</div>
|
||||
{!isSamePage && target.href ? (
|
||||
<Button
|
||||
className={tcls(
|
||||
'-mx-2 -my-2 ml-auto',
|
||||
breadcrumbs?.length === 0 ? 'place-self-center' : null
|
||||
)}
|
||||
variant="blank"
|
||||
href={target.href}
|
||||
target="_blank"
|
||||
label={openInNewTabLabel}
|
||||
size="small"
|
||||
icon="arrow-up-right-from-square"
|
||||
iconOnly={true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
|
||||
</HoverCard>
|
||||
</HoverCardRoot>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export function getOpenAPIContext(args: {
|
||||
chevronDown: <Icon icon="chevron-down" />,
|
||||
chevronRight: <Icon icon="chevron-right" />,
|
||||
plus: <Icon icon="plus" />,
|
||||
copy: <Icon icon="copy" />,
|
||||
check: <Icon icon="check" />,
|
||||
},
|
||||
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
|
||||
renderDocument: (documentProps) => (
|
||||
@@ -81,5 +83,9 @@ export function getOpenAPIContext(args: {
|
||||
id: block.meta?.id,
|
||||
blockKey: block.key,
|
||||
locale,
|
||||
hideSummaryAndDescription:
|
||||
'hideSummaryAndDescription' in block.data
|
||||
? Boolean(block.data.hideSummaryAndDescription)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
|
||||
.openapi-method-head,
|
||||
.openapi-method-options,
|
||||
.openapi-method-trace {
|
||||
.openapi-method-trace,
|
||||
.openapi-method-hook {
|
||||
@apply bg-tint;
|
||||
}
|
||||
|
||||
@@ -454,6 +455,10 @@
|
||||
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
|
||||
}
|
||||
|
||||
.openapi-codesample-header-content .openapi-path-title {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
|
||||
@apply text-[0.813rem];
|
||||
}
|
||||
@@ -478,17 +483,22 @@
|
||||
}
|
||||
|
||||
.openapi-path-server {
|
||||
@apply text-tint hidden md:inline;
|
||||
@apply text-tint inline;
|
||||
}
|
||||
|
||||
.openapi-path .openapi-method {
|
||||
@apply m-0 mt-0.5 items-center flex px-1;
|
||||
.openapi-summary .openapi-path .openapi-method {
|
||||
@apply m-0 items-center flex px-2 py-1 h-6;
|
||||
}
|
||||
|
||||
.openapi-path-title {
|
||||
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
|
||||
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
|
||||
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
|
||||
@apply whitespace-nowrap md:whitespace-normal;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.openapi-path-title-row{
|
||||
@apply flex flex-row items-center;
|
||||
}
|
||||
|
||||
.openapi-path-title[data-deprecated="true"] {
|
||||
@@ -592,18 +602,30 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select > button {
|
||||
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
|
||||
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded leading-none;
|
||||
@apply hover:bg-tint-hover transition-all;
|
||||
}
|
||||
|
||||
.openapi-select:not(.openapi-select-unstyled) > button {
|
||||
@apply border border-tint-subtle bg-tint text-xs;
|
||||
}
|
||||
|
||||
.openapi-select-unstyled > button {
|
||||
@apply p-1 *:truncate max-w-full;
|
||||
}
|
||||
|
||||
.openapi-select > button[data-focused="true"] {
|
||||
@apply outline-primary -outline-offset-1 outline outline-1;
|
||||
@apply outline-primary -outline-offset-1 outline;
|
||||
}
|
||||
|
||||
.openapi-select > button > span.react-aria-SelectValue {
|
||||
@apply shrink truncate flex items-center;
|
||||
}
|
||||
|
||||
.openapi-select > button > .react-aria-SelectValue [slot="description"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.openapi-select > button .openapi-markdown {
|
||||
@apply *:leading-none;
|
||||
}
|
||||
@@ -630,6 +652,14 @@ body:has(.openapi-select-popover) {
|
||||
@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;
|
||||
}
|
||||
|
||||
.openapi-select-item.openapi-select-item-column {
|
||||
@apply flex flex-col gap-1 justify-start items-start;
|
||||
}
|
||||
|
||||
.openapi-select-item [slot="description"] {
|
||||
@apply text-xs text-tint-subtle;
|
||||
}
|
||||
|
||||
.openapi-select button .openapi-markdown,
|
||||
.openapi-select-item .openapi-markdown {
|
||||
@apply text-[0.813rem] *:truncate *:!p-0 *:!m-0 [&>*:not(:first-child)]:hidden;
|
||||
@@ -966,3 +996,24 @@ body:has(.openapi-select-popover) {
|
||||
.openapi-copy-button[data-disabled="true"] {
|
||||
@apply cursor-default;
|
||||
}
|
||||
|
||||
.openapi-path-copy-button {
|
||||
@apply p-1 flex rounded-md;
|
||||
@apply hover:bg-tint;
|
||||
}
|
||||
|
||||
.openapi-path-copy-button-icon {
|
||||
@apply size-6 opacity-0 transition-all hidden sm:flex;
|
||||
}
|
||||
|
||||
.openapi-path:hover .openapi-path-copy-button-icon {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.openapi-path-copy-button-icon svg {
|
||||
@apply text-tint size-4;
|
||||
}
|
||||
|
||||
.openapi-operation-no-summary .openapi-column-preview {
|
||||
@apply pt-0;
|
||||
}
|
||||
@@ -85,11 +85,13 @@ export async function RecordCard(
|
||||
light: {
|
||||
src: lightCover.href,
|
||||
size: lightCover.file?.dimensions,
|
||||
alt: light.alt,
|
||||
},
|
||||
dark: darkCover
|
||||
? {
|
||||
src: darkCover.href,
|
||||
size: darkCover.file?.dimensions,
|
||||
alt: dark.alt,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
@@ -120,8 +122,7 @@ export async function RecordCard(
|
||||
: ['h-auto', 'aspect-video'],
|
||||
objectFits
|
||||
)}
|
||||
priority={isOffscreen ? 'lazy' : 'high'}
|
||||
preload
|
||||
loading={isOffscreen ? 'lazy' : 'eager'}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
|
||||
@@ -229,7 +229,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
},
|
||||
},
|
||||
}}
|
||||
priority="lazy"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<FileIcon
|
||||
@@ -404,7 +404,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
size: image.file?.dimensions,
|
||||
},
|
||||
}}
|
||||
priority="lazy"
|
||||
loading="lazy"
|
||||
/>
|
||||
{image.text}
|
||||
</StyledLink>
|
||||
|
||||
@@ -21,6 +21,12 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
||||
return record.values[definitionId];
|
||||
}
|
||||
|
||||
type RecordCardCover = {
|
||||
contentRef: ContentRefFile | ContentRefURL | null;
|
||||
objectFit?: CardsImageObjectFit;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the covers for a record card.
|
||||
* Returns both the light and dark covers with their content refs and optional object fit.
|
||||
@@ -31,10 +37,7 @@ export function getRecordCardCovers(
|
||||
record: DocumentTableRecord,
|
||||
view: DocumentTableViewCards
|
||||
): {
|
||||
[key in 'light' | 'dark']: {
|
||||
contentRef: ContentRefFile | ContentRefURL | null;
|
||||
objectFit?: CardsImageObjectFit;
|
||||
};
|
||||
[key in 'light' | 'dark']: RecordCardCover;
|
||||
} {
|
||||
const lightValue = view.coverDefinition
|
||||
? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[])
|
||||
@@ -53,10 +56,9 @@ export function getRecordCardCovers(
|
||||
/**
|
||||
* Process a cover value and return the content ref and object fit.
|
||||
*/
|
||||
function processCoverValue(value: DocumentTableImageRecord | string[] | null | undefined): {
|
||||
contentRef: ContentRefFile | ContentRefURL | null;
|
||||
objectFit?: CardsImageObjectFit;
|
||||
} {
|
||||
function processCoverValue(
|
||||
value: DocumentTableImageRecord | string[] | null | undefined
|
||||
): RecordCardCover {
|
||||
if (!value) {
|
||||
return { contentRef: null };
|
||||
}
|
||||
@@ -78,6 +80,7 @@ function processCoverValue(value: DocumentTableImageRecord | string[] | null | u
|
||||
return {
|
||||
contentRef: imageValue.ref,
|
||||
objectFit: imageValue.objectFit,
|
||||
alt: imageValue.alt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithRef,
|
||||
} from 'react';
|
||||
|
||||
import { useHash, useIsMounted } from '@/components/hooks';
|
||||
import { useHash, useListOverflow } from '@/components/hooks';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import type { DocumentBlockTabs } from '@gitbook/api';
|
||||
import { HashLinkButton, hashLinkButtonWrapperStyles } from '../HashLinkButton';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
@@ -46,16 +56,9 @@ const TITLES_MAX = 5;
|
||||
export interface TabsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
type SelectorMapper<Type> = {
|
||||
[Property in keyof Type]: Type[Property];
|
||||
};
|
||||
type TabsInput = {
|
||||
id: string;
|
||||
tabs: SelectorMapper<TabsItem>[];
|
||||
};
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
@@ -66,16 +69,16 @@ interface TabsState {
|
||||
/**
|
||||
* Client side component for the tabs, taking care of interactions.
|
||||
*/
|
||||
export function DynamicTabs(
|
||||
props: TabsInput & {
|
||||
tabsBody: React.ReactNode[];
|
||||
style: ClassValue;
|
||||
block: DocumentBlockTabs;
|
||||
}
|
||||
) {
|
||||
const { id, block, tabs, tabsBody, style } = props;
|
||||
export function DynamicTabs(props: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { id, tabs, className } = props;
|
||||
const router = useRouter();
|
||||
|
||||
const hash = useHash();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [tabsState, setTabsState] = useTabsState();
|
||||
const activeState = useMemo(() => {
|
||||
const input = { id, tabs };
|
||||
@@ -84,41 +87,98 @@ export function DynamicTabs(
|
||||
);
|
||||
}, [id, tabs, tabsState]);
|
||||
|
||||
// Track if the tab has been touched by the user.
|
||||
const touchedRef = useRef(false);
|
||||
|
||||
// To avoid issue with hydration, we only use the state from localStorage
|
||||
// once the component has been mounted.
|
||||
// once the component has been initialized (=mounted).
|
||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||
// and get stuck into an inconsistent state.
|
||||
const mounted = useIsMounted();
|
||||
const active = mounted ? activeState : tabs[0];
|
||||
const active = initialized ? activeState : tabs[0];
|
||||
|
||||
/**
|
||||
* When clicking to select a tab, we:
|
||||
* - mark this specific ID as selected
|
||||
* - store the ID to auto-select other tabs with the same title
|
||||
*/
|
||||
const onSelectTab = React.useCallback(
|
||||
(tab: TabsItem) => {
|
||||
setTabsState((prev) => ({
|
||||
activeIds: {
|
||||
...prev.activeIds,
|
||||
[id]: tab.id,
|
||||
},
|
||||
activeTitles: tab.title
|
||||
? prev.activeTitles
|
||||
.filter((t) => t !== tab.title)
|
||||
.concat([tab.title])
|
||||
.slice(-TITLES_MAX)
|
||||
: prev.activeTitles,
|
||||
}));
|
||||
// When clicking to select a tab, we:
|
||||
// - update the URL hash
|
||||
// - mark this specific ID as selected
|
||||
// - store the ID to auto-select other tabs with the same title
|
||||
const selectTab = useCallback(
|
||||
(tabId: string, manual = true) => {
|
||||
const tab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
touchedRef.current = true;
|
||||
const href = `#${tab.id}`;
|
||||
if (window.location.hash !== href) {
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTabsState((prev) => {
|
||||
if (prev.activeIds[id] === tab.id) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
activeIds: {
|
||||
...prev.activeIds,
|
||||
[id]: tab.id,
|
||||
},
|
||||
activeTitles: tab.title
|
||||
? prev.activeTitles
|
||||
.filter((t) => t !== tab.title)
|
||||
.concat([tab.title])
|
||||
.slice(-TITLES_MAX)
|
||||
: prev.activeTitles,
|
||||
};
|
||||
});
|
||||
},
|
||||
[id, setTabsState]
|
||||
[router, setTabsState, tabs, id]
|
||||
);
|
||||
|
||||
/**
|
||||
* When the hash changes, we try to select the tab containing the targetted element.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!hash) {
|
||||
// When the hash changes, we try to select the tab containing the targetted element.
|
||||
React.useLayoutEffect(() => {
|
||||
setInitialized(true);
|
||||
|
||||
if (hash) {
|
||||
// First check if the hash matches a tab ID.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
selectTab(hash, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check if the hash matches an element inside a tab.
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabPanel = activeElement.closest('[role="tabpanel"]');
|
||||
if (!tabPanel) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectTab(tabPanel.id, false);
|
||||
}
|
||||
}, [selectTab, tabs, hash]);
|
||||
|
||||
// Scroll to active element in the tab.
|
||||
React.useLayoutEffect(() => {
|
||||
// If there is no hash or active tab, nothing to scroll.
|
||||
if (!hash || !active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the tab is touched, we don't want to scroll.
|
||||
if (touchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the hash matches a tab, then the scroll is already done.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,143 +187,237 @@ export function DynamicTabs(
|
||||
return;
|
||||
}
|
||||
|
||||
const tabAncestor = activeElement.closest('[role="tabpanel"]');
|
||||
if (!tabAncestor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectTab(tab);
|
||||
}, [hash, tabs, onSelectTab]);
|
||||
activeElement.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'instant',
|
||||
});
|
||||
}, [active, tabs, hash]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-xs',
|
||||
'ring-1',
|
||||
'ring-inset',
|
||||
'ring-tint-subtle',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'overflow-hidden',
|
||||
style
|
||||
'ring-1 ring-tint-subtle ring-inset',
|
||||
'flex min-w-0 flex-col',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
className={tcls(
|
||||
'group/tabs',
|
||||
'inline-flex',
|
||||
'flex-row',
|
||||
'self-stretch',
|
||||
'after:flex-1',
|
||||
'after:bg-tint-12/1',
|
||||
// if last tab is selected, apply rounded to :after element
|
||||
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={tcls(
|
||||
hashLinkButtonWrapperStyles,
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-3.5',
|
||||
|
||||
//prev from active-tab
|
||||
'[&:has(+_.active-tab)]:rounded-br-md',
|
||||
|
||||
//next from active-tab
|
||||
'[.active-tab+&]:rounded-bl-md',
|
||||
|
||||
//next from active-tab
|
||||
'[.active-tab_+_:after]:rounded-br-md',
|
||||
|
||||
'after:transition-colors',
|
||||
'after:border-r',
|
||||
'after:absolute',
|
||||
'after:left-[unset]',
|
||||
'after:right-0',
|
||||
'after:border-tint',
|
||||
'after:top-[15%]',
|
||||
'after:h-[70%]',
|
||||
'after:w-px',
|
||||
|
||||
'px-3.5',
|
||||
'py-2',
|
||||
|
||||
'last:after:border-transparent',
|
||||
|
||||
'text-tint',
|
||||
'bg-tint-12/1',
|
||||
'hover:text-tint-strong',
|
||||
'max-w-full',
|
||||
'truncate',
|
||||
|
||||
active?.id === tab.id
|
||||
? [
|
||||
'shrink-0',
|
||||
'active-tab',
|
||||
'text-tint-strong',
|
||||
'bg-transparent',
|
||||
'[&.active-tab]:after:border-transparent',
|
||||
'[:has(+_&.active-tab)]:after:border-transparent',
|
||||
'[:has(&_+)]:after:border-transparent',
|
||||
]
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active?.id === tab.id}
|
||||
aria-controls={getTabPanelId(tab.id)}
|
||||
id={getTabButtonId(tab.id)}
|
||||
onClick={() => {
|
||||
onSelectTab(tab);
|
||||
}}
|
||||
className={tcls(
|
||||
'inline-block',
|
||||
'text-sm',
|
||||
'transition-[color]',
|
||||
'font-medium',
|
||||
'relative',
|
||||
'max-w-full',
|
||||
'truncate'
|
||||
)}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
|
||||
<HashLinkButton
|
||||
id={getTabButtonId(tab.id)}
|
||||
block={block}
|
||||
label="Direct link to tab"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tabpanel"
|
||||
id={getTabPanelId(tab.id)}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className={tcls('p-4', tab.id !== active?.id ? 'hidden' : null)}
|
||||
>
|
||||
{tabsBody[index]}
|
||||
</div>
|
||||
<TabItemList tabs={tabs} activeTabId={active?.id ?? null} onSelect={selectTab} />
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} tab={tab} isActive={tab.id === active?.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TabPanel = memo(function TabPanel(props: {
|
||||
tab: TabsItem;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const { tab, isActive } = props;
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={tab.id}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
|
||||
>
|
||||
<div className="p-4" hidden={!isActive}>
|
||||
{tab.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TabItemList = memo(function TabItemList(props: {
|
||||
tabs: TabsItem[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
const { tabs, activeTabId, onSelect } = props;
|
||||
const { containerRef, itemRef, overflowing, isMeasuring } = useListOverflow();
|
||||
const overflowingTabs = useMemo(
|
||||
() =>
|
||||
Array.from(overflowing, (id) => {
|
||||
const tabId = getTabIdFromButtonId(id);
|
||||
return tabs.find((tab) => tab.id === tabId);
|
||||
}).filter((x) => x !== undefined),
|
||||
[overflowing, tabs]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="tablist"
|
||||
className={tcls(
|
||||
'group/tabs',
|
||||
'overflow-hidden',
|
||||
'rounded-t-lg',
|
||||
'straight-corners:rounded-t-xs',
|
||||
'inline-flex',
|
||||
'self-stretch',
|
||||
'after:flex-1',
|
||||
'after:bg-tint-12/1',
|
||||
// if last tab is selected, apply rounded to :after element
|
||||
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md'
|
||||
)}
|
||||
>
|
||||
{/* When we measure, we add the menu at start to be sure everything's fit. */}
|
||||
{isMeasuring ? (
|
||||
<TabsDropdownMenu tabs={tabs} onSelect={onSelect} activeTabId={activeTabId} />
|
||||
) : null}
|
||||
{tabs.map((tab) => {
|
||||
// Hide overflowing tabs when not measuring.
|
||||
if (overflowing.has(getTabButtonId(tab.id)) && !isMeasuring) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
ref={itemRef}
|
||||
isActive={tab.id === activeTabId}
|
||||
tab={tab}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Dropdown for overflowing tabs */}
|
||||
{overflowingTabs.length > 0 && !isMeasuring ? (
|
||||
<TabsDropdownMenu
|
||||
tabs={overflowingTabs}
|
||||
onSelect={onSelect}
|
||||
activeTabId={activeTabId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function TabsDropdownMenu(props: {
|
||||
tabs: TabsItem[];
|
||||
activeTabId: string | null;
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
const { tabs, onSelect, activeTabId } = props;
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={
|
||||
<TabButton
|
||||
isActive={tabs.some((tab) => tab.id === activeTabId)}
|
||||
aria-label={tString(language, 'more')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon="ellipsis" className="size-4" />
|
||||
</TabButton>
|
||||
}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={tab.id}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
active={tab.id === activeTabId}
|
||||
>
|
||||
{tab.title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab item that accepts a `tab` prop.
|
||||
*/
|
||||
const TabItem = memo(function TabItem(props: {
|
||||
ref: React.Ref<HTMLButtonElement>;
|
||||
isActive: boolean;
|
||||
tab: TabsItem;
|
||||
onSelect: (tabId: string) => void;
|
||||
}) {
|
||||
const { ref, tab, isActive, onSelect } = props;
|
||||
return (
|
||||
<TabButton
|
||||
ref={ref}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={tab.id}
|
||||
id={getTabButtonId(tab.id)}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</TabButton>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Generic tab button component, low-level.
|
||||
*/
|
||||
function TabButton(
|
||||
props: Omit<ComponentPropsWithRef<'button'>, 'type'> & {
|
||||
isActive?: boolean;
|
||||
}
|
||||
) {
|
||||
const { isActive, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
'flex items-center',
|
||||
|
||||
//prev from active-tab
|
||||
'[&:has(+_.active-tab)]:rounded-br-md',
|
||||
|
||||
//next from active-tab
|
||||
'[.active-tab+&]:rounded-bl-md',
|
||||
|
||||
//next from active-tab
|
||||
'[.active-tab_+_:after]:rounded-br-md',
|
||||
|
||||
'after:transition-colors',
|
||||
'after:border-r',
|
||||
'after:absolute',
|
||||
'after:left-[unset]',
|
||||
'after:right-0',
|
||||
'after:border-tint',
|
||||
'after:top-[15%]',
|
||||
'after:h-[70%]',
|
||||
'after:w-px',
|
||||
|
||||
'last:after:border-transparent',
|
||||
|
||||
'text-tint',
|
||||
'bg-tint-12/1',
|
||||
'hover:text-tint-strong',
|
||||
'max-w-full',
|
||||
'shrink-0',
|
||||
'truncate',
|
||||
|
||||
props['aria-selected'] || props['aria-expanded'] || isActive
|
||||
? [
|
||||
'active-tab',
|
||||
'text-tint-strong',
|
||||
'bg-transparent',
|
||||
'[&.active-tab]:after:border-transparent',
|
||||
'[:has(+_&.active-tab)]:after:border-transparent',
|
||||
'[:has(&_+)]:after:border-transparent',
|
||||
]
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<button
|
||||
{...rest}
|
||||
type="button"
|
||||
className={tcls(
|
||||
'relative inline-block max-w-full truncate px-3.5 py-2 font-medium text-sm transition-[color]',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for a tab button.
|
||||
*/
|
||||
@@ -272,17 +426,25 @@ function getTabButtonId(tabId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for a tab panel.
|
||||
* We use the ID of the tab itself as links can be pointing to this ID.
|
||||
* Get the ID of a tab from a button ID.
|
||||
*/
|
||||
function getTabPanelId(tabId: string) {
|
||||
return tabId;
|
||||
function getTabIdFromButtonId(buttonId: string) {
|
||||
if (buttonId.startsWith('tab-')) {
|
||||
return buttonId.slice(4);
|
||||
}
|
||||
return buttonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicitly selected tab in a set of tabs.
|
||||
*/
|
||||
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
|
||||
function getTabBySelection(
|
||||
input: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
},
|
||||
state: TabsState
|
||||
): TabsItem | null {
|
||||
const activeId = state.activeIds[input.id];
|
||||
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
||||
}
|
||||
@@ -290,7 +452,13 @@ function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null
|
||||
/**
|
||||
* Get the best selected tab in a set of tabs by taking only title into account.
|
||||
*/
|
||||
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
|
||||
function getTabByTitle(
|
||||
input: {
|
||||
id: string;
|
||||
tabs: TabsItem[];
|
||||
},
|
||||
state: TabsState
|
||||
): TabsItem | null {
|
||||
return (
|
||||
input.tabs
|
||||
.map((item) => {
|
||||
|
||||
@@ -9,47 +9,40 @@ import { DynamicTabs, type TabsItem } from './DynamicTabs';
|
||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
const { block, ancestorBlocks, document, style, context } = props;
|
||||
|
||||
const tabs: TabsItem[] = [];
|
||||
const tabsBody: React.ReactNode[] = [];
|
||||
|
||||
block.nodes.forEach((tab, index) => {
|
||||
tabs.push({
|
||||
id: tab.meta?.id ?? tab.key!,
|
||||
title: tab.data.title ?? '',
|
||||
});
|
||||
|
||||
tabsBody.push(
|
||||
<Blocks
|
||||
key={tab.key ?? index}
|
||||
nodes={tab.nodes}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
||||
context={context}
|
||||
blockStyle={tcls('flip-heading-hash')}
|
||||
style={tcls('w-full', 'space-y-4')}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
if (context.mode === 'print') {
|
||||
// When printing, we display the tab, one after the other
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab, index) => (
|
||||
<DynamicTabs
|
||||
key={tab.id}
|
||||
id={block.key!}
|
||||
block={block}
|
||||
tabs={[tab]}
|
||||
tabsBody={[tabsBody[index]]}
|
||||
style={style}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
if (!block.key) {
|
||||
throw new Error('Tabs block is missing a key');
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicTabs id={block.key!} block={block} tabs={tabs} tabsBody={tabsBody} style={style} />
|
||||
);
|
||||
const id = block.key;
|
||||
|
||||
const tabs: TabsItem[] = block.nodes.map((tab) => {
|
||||
if (!tab.key) {
|
||||
throw new Error('Tab block is missing a key');
|
||||
}
|
||||
|
||||
return {
|
||||
id: tab.meta?.id ?? tab.key,
|
||||
title: tab.data.title ?? '',
|
||||
body: (
|
||||
<Blocks
|
||||
key={tab.key}
|
||||
nodes={tab.nodes}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
||||
context={context}
|
||||
blockStyle="flip-heading-hash"
|
||||
style="w-full space-y-4"
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// When printing, we display the tab, one after the other
|
||||
if (context.mode === 'print') {
|
||||
return tabs.map((tab) => {
|
||||
return <DynamicTabs key={tab.id} id={id} tabs={[tab]} className={tcls(style)} />;
|
||||
});
|
||||
}
|
||||
|
||||
return <DynamicTabs id={id} tabs={tabs} className={tcls(style)} />;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority="lazy"
|
||||
loading="lazy"
|
||||
style={[
|
||||
'w-auto',
|
||||
'max-w-40',
|
||||
|
||||
@@ -51,6 +51,7 @@ export function CurrentContentIcon(
|
||||
},
|
||||
}
|
||||
}
|
||||
preload
|
||||
{...imageProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,7 @@ export function Header(props: {
|
||||
s.object === 'site-section' && s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={!!sections}
|
||||
withSections={sections ? sections.list.length > 1 : false}
|
||||
section={
|
||||
sections
|
||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||
@@ -188,13 +188,12 @@ export function Header(props: {
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
className="my-2 ml-2 self-start"
|
||||
/>
|
||||
) : null}
|
||||
</SiteSectionTabs>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
width: 260,
|
||||
},
|
||||
]}
|
||||
priority="high"
|
||||
preload
|
||||
style={tcls(
|
||||
'overflow-hidden',
|
||||
'shrink',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
type PageActionType = 'button' | 'dropdown-menu-item';
|
||||
|
||||
@@ -41,18 +41,13 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct
|
||||
type CopiedStore = {
|
||||
copied: boolean;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||
};
|
||||
|
||||
// We need to store everything in a store to share the state between every instance of the component.
|
||||
const useCopiedStore = create<
|
||||
CopiedStore & {
|
||||
setLoading: (loading: boolean) => void;
|
||||
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||
}
|
||||
>((set) => {
|
||||
const createCopiedStateStore = () => {
|
||||
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return {
|
||||
return createStore<CopiedStore>()((set) => ({
|
||||
copied: false,
|
||||
loading: false,
|
||||
setLoading: (loading: boolean) => set({ loading }),
|
||||
@@ -70,13 +65,25 @@ const useCopiedStore = create<
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
onSuccess?.();
|
||||
|
||||
// Reset the timeout ref to avoid multiple timeouts
|
||||
timeoutRef = null;
|
||||
}, 1500);
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
const copiedStores = new Map<string, ReturnType<typeof createCopiedStateStore>>();
|
||||
|
||||
const getOrCreateCopiedStoreByKey = (storeKey: string) => {
|
||||
const existing = copiedStores.get(storeKey);
|
||||
if (existing) return existing;
|
||||
const created = createCopiedStateStore();
|
||||
copiedStores.set(storeKey, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
function useCopiedStore(stateKey: string) {
|
||||
return useStore(getOrCreateCopiedStoreByKey(stateKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for the markdown versbion of the page.
|
||||
@@ -96,7 +103,7 @@ export function ActionCopyMarkdown(props: {
|
||||
|
||||
const closeDropdown = useDropdownMenuClose();
|
||||
|
||||
const { copied, loading, setLoading, copy } = useCopiedStore();
|
||||
const { copied, loading, setLoading, copy } = useCopiedStore('markdown');
|
||||
|
||||
// Fetch the markdown from the page
|
||||
const fetchMarkdown = async () => {
|
||||
@@ -293,14 +300,29 @@ export function CopyToClipboard(props: {
|
||||
icon: IconName;
|
||||
}) {
|
||||
const { type, data, label, description, icon } = props;
|
||||
|
||||
const closeDropdown = useDropdownMenuClose();
|
||||
|
||||
const language = useLanguage();
|
||||
const labelKey = label.toLowerCase().replace(/\s+/g, '_');
|
||||
const { copied, copy } = useCopiedStore(labelKey);
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
type={type}
|
||||
icon={icon}
|
||||
label={label}
|
||||
icon={copied ? 'check' : icon}
|
||||
label={copied ? tString(language, 'code_copied') : label}
|
||||
description={description}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(data);
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
copy(data, {
|
||||
onSuccess: () => {
|
||||
if (type === 'dropdown-menu-item') {
|
||||
closeDropdown();
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { PageFooterNavigation } from './PageFooterNavigation';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { PreservePageLayout } from './PreservePageLayout';
|
||||
|
||||
const LINK_PREVIEW_MAX_COUNT = 100;
|
||||
const LINK_PREVIEW_MAX_COUNT = 500;
|
||||
|
||||
export function PageBody(props: {
|
||||
context: GitBookSiteContext;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { assert } from 'ts-essentials';
|
||||
import { PageCoverImage } from './PageCoverImage';
|
||||
import { getCoverHeight } from './coverHeight';
|
||||
import defaultPageCoverSVG from './default-page-cover.svg';
|
||||
|
||||
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
|
||||
@@ -22,6 +23,12 @@ export async function PageCover(props: {
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { as, page, cover, context } = props;
|
||||
const height = getCoverHeight(cover);
|
||||
|
||||
if (!height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [resolved, resolvedDark] = await Promise.all([
|
||||
cover.ref ? resolveContentRef(cover.ref, context) : null,
|
||||
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
|
||||
@@ -78,6 +85,7 @@ export async function PageCover(props: {
|
||||
<div
|
||||
id="page-cover"
|
||||
data-full={String(as === 'full')}
|
||||
style={{ height }}
|
||||
className={tcls(
|
||||
'overflow-hidden',
|
||||
// Negative margin to balance the container padding
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useRef } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
import type { ImageSize } from '../utils';
|
||||
import { useCoverPosition } from './useCoverPosition';
|
||||
|
||||
interface ImageAttributes {
|
||||
src: string;
|
||||
@@ -18,27 +17,16 @@ interface Images {
|
||||
dark?: ImageAttributes;
|
||||
}
|
||||
|
||||
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
|
||||
|
||||
function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) {
|
||||
// When the size of the image hasn't been determined, we fallback to the center position
|
||||
if (!img.size || y === 0) return '50%';
|
||||
const ratio =
|
||||
container.height && container.width
|
||||
? Math.max(container.width / img.size.width, container.height / img.size.height)
|
||||
: 1;
|
||||
const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height;
|
||||
const top =
|
||||
container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y;
|
||||
return `${top}px`;
|
||||
}
|
||||
|
||||
export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
|
||||
const container = useResizeObserver({
|
||||
ref: containerRef,
|
||||
});
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
@@ -48,10 +36,9 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
sizes={imgs.light.sizes}
|
||||
fetchPriority="high"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
className={tcls('h-full', 'w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
}}
|
||||
/>
|
||||
{imgs.dark && (
|
||||
@@ -61,10 +48,9 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
sizes={imgs.dark.sizes}
|
||||
fetchPriority="low"
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
className={tcls('h-full', 'w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -113,6 +113,7 @@ export async function PageHeader(props: {
|
||||
>
|
||||
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
|
||||
{page.title}
|
||||
{'computed' in page && page.computed?.props.deprecated ? <span className="openapi-deprecated">Deprecated</span> : null}
|
||||
</h1>
|
||||
) : null}
|
||||
{page.description && page.layout.description ? (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RevisionPageDocumentCover } from '@gitbook/api';
|
||||
|
||||
export const DEFAULT_COVER_HEIGHT = 240;
|
||||
export const MIN_COVER_HEIGHT = 10;
|
||||
export const MAX_COVER_HEIGHT = 700;
|
||||
|
||||
// Normalize and clamp the cover height between the minimum and maximum heights
|
||||
function clampCoverHeight(height: number | null | undefined): number {
|
||||
if (typeof height !== 'number' || Number.isNaN(height)) {
|
||||
return DEFAULT_COVER_HEIGHT;
|
||||
}
|
||||
|
||||
return Math.min(MAX_COVER_HEIGHT, Math.max(MIN_COVER_HEIGHT, height));
|
||||
}
|
||||
|
||||
export function getCoverHeight(
|
||||
cover: RevisionPageDocumentCover | null | undefined
|
||||
): number | undefined {
|
||||
// Cover (and thus height) is not defined
|
||||
if (!cover) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './PageBody';
|
||||
export * from './PageCover';
|
||||
export * from './useCoverPosition';
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
|
||||
interface ImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ImageAttributes {
|
||||
src: string;
|
||||
srcSet?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: ImageSize;
|
||||
}
|
||||
|
||||
interface Images {
|
||||
light: ImageAttributes;
|
||||
dark?: ImageAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate the object position Y percentage for a cover image
|
||||
* based on the y offset, image dimensions, and container dimensions.
|
||||
*/
|
||||
export function useCoverPosition(imgs: Images, y: number) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
|
||||
|
||||
const container = useResizeObserver({
|
||||
// @ts-expect-error wrong types
|
||||
ref: containerRef,
|
||||
});
|
||||
|
||||
// Load original image dimensions if not provided in `imgs`
|
||||
useLayoutEffect(() => {
|
||||
// Check if we have dimensions from either light or dark image
|
||||
const hasDimensions = imgs.light.size || imgs.dark?.size;
|
||||
|
||||
if (hasDimensions) {
|
||||
return; // Already have dimensions
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Load the original image (using src, not srcSet) to get true dimensions
|
||||
// Use dark image if available, otherwise fall back to light
|
||||
const imageToLoad = imgs.dark || imgs.light;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setLoadedDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
setIsLoading(false);
|
||||
};
|
||||
img.onerror = () => {
|
||||
// If image fails to load, use a fallback
|
||||
setIsLoading(false);
|
||||
};
|
||||
img.src = imageToLoad.src;
|
||||
}, [imgs.light, imgs.dark]);
|
||||
|
||||
// Use provided dimensions or fall back to loaded dimensions
|
||||
// Check light first, then dark, then loaded dimensions
|
||||
const imageDimensions = imgs.light.size ?? imgs.dark?.size ?? loadedDimensions;
|
||||
|
||||
// Calculate ratio and dimensions similar to useCoverPosition hook
|
||||
const ratio =
|
||||
imageDimensions && container.height && container.width
|
||||
? Math.max(
|
||||
container.width / imageDimensions.width,
|
||||
container.height / imageDimensions.height
|
||||
)
|
||||
: 1;
|
||||
const safeRatio = ratio || 1;
|
||||
|
||||
const scaledHeight =
|
||||
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
|
||||
const maxOffset =
|
||||
scaledHeight && container.height
|
||||
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
|
||||
: 0;
|
||||
|
||||
// Parse the position between the allowed min/max
|
||||
const objectPositionY = useMemo(() => {
|
||||
if (!container.height || !imageDimensions) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const scaled = imageDimensions.height * safeRatio;
|
||||
if (scaled <= container.height || maxOffset === 0) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
|
||||
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
|
||||
return relative * 100;
|
||||
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
objectPositionY,
|
||||
isLoading: !imageDimensions || isLoading,
|
||||
};
|
||||
}
|
||||
@@ -27,4 +27,6 @@ export function PageIcon(props: {
|
||||
if (page.icon) {
|
||||
return <Icon icon={page.icon as IconName} className={tcls('size-[1em]', style)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user