Compare commits

..

28 Commits

Author SHA1 Message Date
Zeno Kapitein ddcb22e283 Format 2026-01-28 14:01:16 +01:00
Zeno Kapitein 0beed6bbf8 Update bun.lock 2026-01-28 13:59:52 +01:00
Marcos Cisneros 2d7954f478 Enhance layout handling for OpenAPI and full-width modes
- Removed deprecated configuration from bun.lock.
- Added new layout variants in tailwind.config.ts for better control over page structure.
- Updated DocumentView and related components to support full-width and OpenAPI layouts, ensuring proper styling and responsiveness.
- Introduced utility functions to check for OpenAPI blocks in documents.
- Adjusted various components to conditionally apply styles based on the layout mode, improving overall layout consistency.
2026-01-28 13:50:48 +01:00
Zeno Kapitein e00d1c5bbd Support code themes customization option, refactor built-in code themes (#3949) 2026-01-28 13:48:56 +01:00
conico974 9063b56c89 Fix for the assistant (#3948) 2026-01-28 11:16:50 +01:00
Greg Bergé 020d3668d7 Make the button icon validation safer (#3955) 2026-01-28 10:44:52 +01:00
Greg Bergé 4d30cbf058 Fix social links not appearing on desktop (#3950) 2026-01-28 10:44:40 +01:00
Samy Pessé d3f6c38f05 Update scalar dependencies (#3956) 2026-01-28 10:29:56 +01:00
Samy Pessé 6f1db32682 Simplify local development and contributions (#3952) 2026-01-28 09:32:56 +01:00
Samy Pessé 4ad6b2c067 Increase rollout of reusable content deref in the API to 80% (#3951) 2026-01-28 09:22:19 +01:00
Viktor Renkema 07449443a0 Fix rendering of Expandables within Update blocks (#3947) 2026-01-27 20:26:33 +01:00
Greg Bergé b09cc64fec Fix GIF rendering (#3946) 2026-01-27 17:34:02 +01:00
Greg Bergé 1eed2004f6 Avoid keyboard-shortcut flicker by adding OS class and using CSS (#3944) 2026-01-27 17:02:49 +01:00
Greg Bergé 91539b8187 Fix navigation loader (#3945) 2026-01-27 17:01:06 +01:00
Samy Pessé 16cfde87ee Increase rollout of reusable content deref in the API to 50% (#3943) 2026-01-27 12:22:09 +01:00
conico974 58b4a978ff Update Next.js dependency to version 15.4.11 (#3940) 2026-01-27 09:26:11 +01:00
Viktor Renkema e34be0e89b Fix logo sizing bug during navigation (#3928) 2026-01-27 08:46:37 +01:00
Samy Pessé e60c754343 Increase rollout of reusable content deref in the API to 30% (#3939) 2026-01-26 16:22:13 +01:00
Nolann B. 48ca21e471 Add OpenAPI 3.1 nullable array support (#3938) 2026-01-26 14:05:14 +01:00
Nolann B. 3ba9e46f2b Improve OpenAPI server URL validation (#3937) 2026-01-26 13:45:03 +01:00
Greg Bergé 58f0cc8287 Fix search field clear button (#3936) 2026-01-24 16:26:03 +01:00
Nolann B. 29e2b22b63 Fix ordered list item index calculation (#3935) 2026-01-23 16:01:33 +01:00
Greg Bergé 3c7cb09c1b Respect DPR to ensure a good image quality (#3931) 2026-01-23 11:45:00 +01:00
Greg Bergé 0f1cd6745d Refactor search (#3932) 2026-01-23 11:13:20 +01:00
Samy Pessé e90f57b138 Deference reusable content at the API level (#3847) 2026-01-23 10:34:44 +01:00
conico974 7bd5939247 Bust data cache (#3927) 2026-01-22 13:59:35 +01:00
Zeno Kapitein 89de966e7a Support social accounts customization option (#3916) 2026-01-22 13:57:40 +01:00
Greg Bergé 4e2d86318e Fix OpenAPI example not showing (#3930)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 12:11:55 +01:00
104 changed files with 2814 additions and 1578 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": minor
"@gitbook/react-openapi": patch
---
Support code themes customization
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support social links
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Fix OpenAPI example not showing
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/openapi-parser": patch
---
Update scalar dependencies.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Improve OpenAPI server URL validation
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ordered list item index calculation
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": patch
---
Simplify installation by using public fontawesome mirror.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Add OpenAPI 3.1 nullable array support
+2 -7
View File
@@ -5,8 +5,6 @@ on:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -36,13 +34,10 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
build:
runs-on: ubuntu-latest
name: Build (Open Source)
name: Build
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
-2
View File
@@ -4,8 +4,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
-2
View File
@@ -3,8 +3,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
-2
View File
@@ -3,8 +3,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
-3
View File
@@ -4,9 +4,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
+1 -17
View File
@@ -77,23 +77,7 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
```
and then reinstall dependencies.
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
## Contributing
+30 -37
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.4",
"turbo": "^2.7.6",
"vercel": "^39.4.2",
},
},
@@ -158,7 +158,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "15.4.10",
"next": "15.4.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -176,7 +176,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
@@ -225,8 +225,8 @@
"gitbook-icons": "./bin/gitbook-icons.js",
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@gitbook/fontawesome-pro": "1.0.16",
},
"devDependencies": {
"@types/react": "catalog:",
@@ -235,9 +235,6 @@
"tsdown": "catalog:",
"typescript": "catalog:",
},
"optionalDependencies": {
"@gitbook/fontawesome-pro": "1.0.16",
},
"peerDependencies": {
"react": "*",
},
@@ -246,8 +243,8 @@
"name": "@gitbook/openapi-parser",
"version": "3.0.7",
"dependencies": {
"@scalar/json-magic": "^0.8.10",
"@scalar/openapi-parser": "^0.23.12",
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/openapi-types": "^0.5.3",
},
"devDependencies": {
@@ -348,7 +345,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.158.0",
"@gitbook/api": "0.159.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -725,11 +722,9 @@
"@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.1.0", "", {}, "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.1.0", "", {}, "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA=="],
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="],
"@gitbook/api": ["@gitbook/api@0.159.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-IRZXT2NAcOlKlCcGnzRH4w82AuBXam5I7lw84qfCRbNDDycvFNBwIVMHu3JMOcGUTCkzj7QDElaNKYirsWDwFQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -875,7 +870,7 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@15.4.10", "", {}, "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg=="],
"@next/env": ["@next/env@15.4.11", "", {}, "sha512-mIYp/091eYfPFezKX7ZPTWqrmSXq+ih6+LcUyKvLmeLQGhlPtot33kuEOd4U+xAA7sFfj21+OtCpIZx0g5SpvQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A=="],
@@ -1289,23 +1284,23 @@
"@scalar/draggable": ["@scalar/draggable@0.2.0", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-UetHRB5Bqo5egVYlS21roWBcICmyk8CKh2htsidO+bFGAjl2e7Te+rY0borhNrMclr0xezHlPuLpEs1dvgLS2g=="],
"@scalar/helpers": ["@scalar/helpers@0.2.6", "", {}, "sha512-A471YFBCj7ZOlGIkAYnU8oYgeyts82ZNX+4UicrlmKv3eAQ+kwboN3Dy0R6u1lcA/+I/zzeXi/fBObsT7P9qTA=="],
"@scalar/helpers": ["@scalar/helpers@0.2.9", "", {}, "sha512-Y4ffJF0yELdwZ0BKgonqn3SumIgRn1WKyYCVHD+TDM7qRFChdGRypyt20+efHs26fmJeyBAIIv2laICj5uimiw=="],
"@scalar/icons": ["@scalar/icons@0.4.7", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^22.9.0", "chalk": "^5.4.1", "vue": "^3.5.17" } }, "sha512-0qXPGRdZ180TMfejWCPYy7ILszBrAraq4KBhPtcM12ghc5qkncFWWpTm5yXI/vrbm10t7wvtTK08CLZ36CnXlQ=="],
"@scalar/import": ["@scalar/import@0.4.31", "", { "dependencies": { "@scalar/helpers": "0.0.12", "@scalar/openapi-parser": "0.22.3", "yaml": "2.8.0" } }, "sha512-3+3dHz+EvS/C4YEQ8eEihZFVxRVZODsMEHsp48tvXTaIs1Qxb4kf3Nkaf9E3k+6rpM2CUrF59K1P8LvhXob48w=="],
"@scalar/json-magic": ["@scalar/json-magic@0.8.10", "", { "dependencies": { "@scalar/helpers": "0.2.6", "yaml": "^2.8.0" } }, "sha512-TWdKQ/hcy4erFQDp2MVlFoPesFep2VY96Q69cjLHmx5hxM0ZUBfmNB4lA8Uh3klgx5JmCDfSNIGjPFIpxlosUw=="],
"@scalar/json-magic": ["@scalar/json-magic@0.9.4", "", { "dependencies": { "@scalar/helpers": "0.2.9", "yaml": "^2.8.0" } }, "sha512-PyfyWrH4ZkW0TM1ColiiHj4NRF8hUM61H0UzAkHLhRNnKFxi6hI+oqNrwqPnyk93hrpkpTRHC7Fl5T0BRwuzVg=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.6.3", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.1.1", "@scalar/json-magic": "0.8.1", "@scalar/object-utils": "1.2.11", "@scalar/openapi-types": "0.5.1", "@scalar/themes": "0.13.23", "@scalar/types": "0.4.0", "@scalar/workspace-store": "0.20.0", "@types/har-format": "^1.2.15", "flatted": "^3.3.3", "js-base64": "^3.7.8", "microdiff": "^1.5.0", "nanoid": "5.1.5", "type-fest": "5.0.0", "yaml": "2.8.0", "zod": "4.1.11" } }, "sha512-+hEMZSLuTeXlrPqpajusZn2R8qs/OrH2Xstllr3GOCnJz7ATwniD69DT7Il49L1j2HJEtX4EpleRX9OMhdoWHg=="],
"@scalar/object-utils": ["@scalar/object-utils@1.2.11", "", { "dependencies": { "@scalar/helpers": "0.1.1", "flatted": "^3.3.3", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "5.0.0" } }, "sha512-V44CbfjNuYhoVmVZ36DDlTfyBNF8dMYzLTcDz5zvoSsx+SViXE8ScMd+kgUMHCtSJZV6izV2x3UzIbGQY/pWsQ=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.23.12", "", { "dependencies": { "@scalar/json-magic": "0.8.10", "@scalar/openapi-types": "0.5.3", "@scalar/openapi-upgrader": "0.1.6", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.0" } }, "sha512-Znq9J8sXUe20YdbK2myoyeFNXgWTeZlyQUJeQu+wNsXAfWHGT68bF7aZzl99TsPHHVl3WmKDeSrWiuexfJ1u6A=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.24.5", "", { "dependencies": { "@scalar/json-magic": "0.9.4", "@scalar/openapi-types": "0.5.3", "@scalar/openapi-upgrader": "0.1.8", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.0" } }, "sha512-pTeKnmhVdSIfG3vysgDm6jsKc7Do1vXdy/4aqp7j8AEzXllf8RZjSgRSUhtvFYFQCr27fDZ117V3WPQUYtgmCw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.5.3", "", { "dependencies": { "zod": "^4.1.11" } }, "sha512-m4n/Su3K01d15dmdWO1LlqecdSPKuNjuokrJLdiQ485kW/hRHbXW1QP6tJL75myhw/XhX5YhYAR+jrwnGjXiMw=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.6", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-XdrNZUr0ASLfR89OS2zP6enbq9f7UGQQxov+a3WF1Wz9DClniAL2ChJ2fbGOrqL5F2kjbV6Fw/iO3bsBTMyLZA=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.8", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-2xuYLLs0fBadLIk4I1ObjMiCnOyLPEMPf24A1HtHQvhKGDnGlvT63F2rU2Xw8lxCjgHnzveMPnOJEbwIy64RCg=="],
"@scalar/postman-to-openapi": ["@scalar/postman-to-openapi@0.3.40", "", { "dependencies": { "@scalar/helpers": "0.0.12", "@scalar/oas-utils": "0.5.2", "@scalar/openapi-types": "0.5.0" } }, "sha512-FP1p2/mb0Y5GM9hc+TI9ldDM44VV9GHwdhJQ8xGpArtlt8nxtyKmncOXcgayBD7qk3ohV6W1Eftsr258Eq7gGQ=="],
@@ -1325,17 +1320,17 @@
"@scalar/workspace-store": ["@scalar/workspace-store@0.20.0", "", { "dependencies": { "@scalar/code-highlight": "0.2.0", "@scalar/helpers": "0.1.1", "@scalar/json-magic": "0.8.1", "@scalar/openapi-upgrader": "0.1.4", "@scalar/snippetz": "0.5.2", "@scalar/themes": "0.13.23", "@scalar/typebox": "0.1.1", "@scalar/types": "0.4.0", "github-slugger": "^2.0.0", "type-fest": "5.0.0", "vue": "^3.5.21", "yaml": "2.8.0" } }, "sha512-RklQSKjz5V/69rKjxQV70q4u34qzmUZVtWGUJLVvHxPsPxCtxPPRLYY+gdQDkGjTQYol/dR9xyvi2QGk3OWTKA=="],
"@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="],
"@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="],
"@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="],
"@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="],
"@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="],
"@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="],
"@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="],
"@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -1903,8 +1898,6 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -2515,7 +2508,7 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["next@15.4.10", "", { "dependencies": { "@next/env": "15.4.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ=="],
"next": ["next@15.4.11", "", { "dependencies": { "@next/env": "15.4.11", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-IJRyXal45mIsshZI5XJne/intjusslUP1F+FHVBIyMGEqbYtIq1Irdx5vdWBBg58smviPDycmDeV6txsfkv1RQ=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2557,9 +2550,9 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.5.4", "", {}, "sha512-yNxcQ8sKvURiTwP0mV6bLQCYE7NKfKRRWunhbZnXgxSmB1OXa1lHrN3o4DZd+0Si0kU5blidK7BcROO8qv5TZA=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-to-es": ["oniguruma-to-es@4.1.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "oniguruma-parser": "^0.5.4", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-SNwG909cSLo4vPyyPbU/VJkEc9WOXqu2ycBlfd1UCXLqk1IijcQktSBb2yRQ2UFPsDhpkaf+C1dtT3PkLK/yWA=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
"openapi-fetch": ["openapi-fetch@0.13.5", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-AQK8T9GSKFREFlN1DBXTYsLjs7YV2tZcJ7zUWxbjMoQmj8dDSFRrzhLCbHPZWA1TMV3vACqfCxLEZcwf2wxV6Q=="],
@@ -2779,7 +2772,7 @@
"shell-quote": ["shell-quote@1.8.1", "", {}, "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA=="],
"shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="],
"shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="],
"side-channel": ["side-channel@1.0.6", "", { "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" } }, "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA=="],
@@ -2913,19 +2906,19 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.7.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.4", "turbo-darwin-arm64": "2.7.4", "turbo-linux-64": "2.7.4", "turbo-linux-arm64": "2.7.4", "turbo-windows-64": "2.7.4", "turbo-windows-arm64": "2.7.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g=="],
"turbo": ["turbo@2.7.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.6", "turbo-darwin-arm64": "2.7.6", "turbo-linux-64": "2.7.6", "turbo-linux-arm64": "2.7.6", "turbo-windows-64": "2.7.6", "turbo-windows-arm64": "2.7.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-PO9AvJLEsNLO+EYhF4zB+v10hOjsJe5kJW+S6tTbRv+TW7gf1Qer4mfjP9h3/y9h8ZiPvOrenxnEgDtFgaM5zw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDR30ltfkSsRfGzABBckvl1nz1cZ3ssTujvdj+TPwOweeDRvZ0e06t5DS0rmRBvyKpgGs42K/EK6Mn2qLlFY9A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-bYu0qnWju2Ha3EbIkPCk1SMLT3sltKh1P/Jy5FER6BmH++H5z+T5MHh3W1Xoers9rk4N1VdKvog9FO1pxQyjhw=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7sjqXtOL/+nYWPvcDGWhi8wf8M8mZHHB8XEzw2VX7VJrS8IGHyJHGD1AYfDvhAEcr7pnk3gGifz3/xyhI655w=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KCxTf3Y1hgNLYIWRLw8bwH8Zie9RyCGoxAlXYsCBI/YNqBSR+ZZK9KYzFxAqDaVaNvTwLFv3rJRGsXOFWg4+Uw=="],
"turbo-linux-64": ["turbo-linux-64@2.7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GofFOxRO/IhG8BcPyMSSB3Y2+oKQotsaYbHxL9yD6JPb20/o35eo+zUSyazOtilAwDHnak5dorAJFoFU8MIg2A=="],
"turbo-linux-64": ["turbo-linux-64@2.7.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vjoU8zIfNgvJR3cMitgw7inEoi6bmuVuFawDl5yKtxjAEhDktFdRBpGS3WojD4l3BklBbIK689ssXcGf21LxRA=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+RQKgNjksVPxYAyAgmDV7w/1qj++qca+nSNTAOKGOfJiDtSvRKoci89oftJ6anGs00uamLKVEQ712TI/tfNAIw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TcMpBvTqZf+1DptrVYLbZls7WY1UVNDTGaf0bo7/GCgWYv5eZHCVo4Td7kCJeDU4glbXg67REX0md0S0V6ghMg=="],
"turbo-windows-64": ["turbo-windows-64@2.7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-rfak1+g+ON3czs1mDYsCS4X74ZmK6gOgRQTXjDICtzvR4o61paqtgAYtNPofcVsMWeF4wvCajSeoAkkeAnQ1kg=="],
"turbo-windows-64": ["turbo-windows-64@2.7.6", "", { "os": "win32", "cpu": "x64" }, "sha512-1/MhkYldiihjneY8QnnDMbAkHXn/udTWSVYS94EMlkE9AShozsLTTOT1gDOpX06EfEW5njP09suhMvxbvwuwpQ=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-1ZgBNjNRbDu/fPeqXuX9i26x3CJ/Y1gcwUpQ+Vp7kN9Un6RZ9kzs164f/knrjcu5E+szCRexVjRSJay1k5jApA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0wDVnUJLFAWm4ZzOQFDkbyyUqaszorTGf3Rdc22IRIyJTTLd6ajqdb+cWD89UZ1RKr953+PZR1gqgWQY4PDuhA=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
-2
View File
@@ -1,2 +0,0 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+3 -3
View File
@@ -7,10 +7,10 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.4",
"turbo": "^2.7.6",
"vercel": "^39.4.2"
},
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.3.7",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.158.0",
"@gitbook/api": "0.159.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+41
View File
@@ -8,6 +8,7 @@ import {
CustomizationIconsStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { expect } from '@playwright/test';
@@ -1123,6 +1124,16 @@ const testCases: TestsCase[] = [
},
]),
},
{
name: 'Reusable contents',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/reusable-contents/',
tests: [
{
name: 'All cases',
url: '',
},
],
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1139,6 +1150,36 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Social links',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'With social links',
url: getCustomizationURL({
socialAccounts: [
{
platform: SiteSocialAccountPlatform.Github,
display: { footer: true },
handle: 'GitbookIO',
},
{
platform: SiteSocialAccountPlatform.Linkedin,
display: { footer: true },
handle: 'gitbook',
},
{
platform: SiteSocialAccountPlatform.Twitter,
display: { footer: false },
handle: 'GitBookIO',
},
],
}),
fullPage: true,
run: waitForCookiesDialog,
},
],
},
{
name: 'Ads',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+11
View File
@@ -2,6 +2,7 @@ import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCodeTheme,
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
@@ -298,6 +299,16 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
links: CustomizationLinksStyle.Default,
codeTheme: {
default: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
openapi: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
},
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
+2 -2
View File
@@ -49,7 +49,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "15.4.10",
"next": "15.4.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -67,7 +67,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
+2 -2
View File
@@ -10,7 +10,7 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch';
import { useSetSearchState } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place
export type AIConfig = {
@@ -84,7 +84,7 @@ export function useAI(): AIContext {
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const assistants: Assistant[] = [];
@@ -13,7 +13,7 @@ import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { useTrackEvent } from '../Insights';
import { integrationsAssistantTools } from '../Integrations';
import { useSearch } from '../Search';
import { useSetSearchState } from '../Search';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { useAIMessageContextRef } from './useAIMessageContext';
@@ -163,7 +163,7 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const language = useLanguage();
// Event listeners storage
@@ -4,6 +4,7 @@ import { MotionConfig } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitorSession } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { DateRelative } from '../primitives';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
@@ -21,7 +22,6 @@ import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
@@ -132,7 +132,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -141,7 +141,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} /> by {author}
<DateRelative value={changeRequest.updatedAt} /> by {author}
</>
}
/>
@@ -207,13 +207,13 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <ToolbarDate value={revision.createdAt} />
Created <DateRelative value={revision.createdAt} />
</>
}
/>
@@ -280,10 +280,20 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
@@ -295,13 +305,13 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open site in GitBook */}
<ToolbarButton
title="View site configuration"
title="Open site in GitBook"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="folder-gear"
icon="gears"
/>
{/* Customize in GitBook */}
@@ -351,13 +361,13 @@ function EditPageButton(props: {
return (
<ToolbarButton
title="Edit this page"
title="Edit in GitBook"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pen-to-square"
icon="pencil"
motionValues={motionValues}
/>
);
@@ -8,20 +8,10 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
const ARC_PARAMS = {
arcWidth: 505,
arcHeight: 400,
arcRadius: 34,
startDistance: -240,
spreadDistance: 45,
fromDistance: -286,
fromSpread: 0,
durationSeconds: 0.6,
staggerMs: 80,
baseRotationDeg: 95,
rotationStepDeg: 18,
offsetAnchorY: 40,
} as const;
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
@@ -33,75 +23,30 @@ interface HideToolbarButtonProps {
export function HideToolbarButton(props: HideToolbarButtonProps) {
const { motionValues } = props;
const [open, setOpen] = React.useState(false);
const [closing, setClosing] = React.useState(false);
const [shouldMirror, setShouldMirror] = React.useState(false);
const [shouldFlipVertical, setShouldFlipVertical] = React.useState(false);
const controls = useToolbarControls();
const closingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const close = React.useCallback(() => {
if (!open || closing) return;
setClosing(true);
// Clear any existing timeout
if (closingTimeoutRef.current) {
clearTimeout(closingTimeoutRef.current);
}
// Wait for the exit animation to complete before unmounting
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
closingTimeoutRef.current = setTimeout(() => {
setOpen(false);
setClosing(false);
closingTimeoutRef.current = null;
}, totalDuration);
}, [open, closing]);
// Clean up timeout on unmount
React.useEffect(() => {
return () => {
if (closingTimeoutRef.current) {
clearTimeout(closingTimeoutRef.current);
}
};
}, []);
const handleClickOutsideArcMenu = (event: Event) => {
// Don't close the arc if we are clicking on the button itself
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
close();
setOpen(false);
};
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Measure button position to decide arc direction when opening
React.useEffect(() => {
if (open && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const distanceFromRight = window.innerWidth - rect.right;
setShouldMirror(distanceFromRight < 250);
setShouldFlipVertical(rect.top < 250);
}
}, [open]);
// Close arc menu on scroll or resize
// Close arc menu on scroll
React.useEffect(() => {
if (!open) return;
const handleClose = () => close();
window.addEventListener('scroll', handleClose, { passive: true });
window.addEventListener('resize', handleClose, { passive: true });
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleClose);
window.removeEventListener('resize', handleClose);
};
}, [open, close]);
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
const items = [
controls?.minimize
@@ -139,80 +84,36 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
return (
<ToolbarButton
ref={buttonRef}
title={open ? undefined : 'Hide toolbar'}
className={
open || closing
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
: undefined
}
title={open ? 'Hide options' : 'Hide toolbar'}
onClick={() => {
if (open || closing) {
close();
} else {
setOpen(true);
}
setOpen((v) => !v);
}}
motionValues={motionValues}
icon="gear"
icon="eye-slash"
>
{/* Expanding arc menu */}
{(open || closing) && (
{open && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={
{
...sharedMotionStyle,
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
'--start-distance': `${ARC_PARAMS.startDistance}px`,
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
} as React.CSSProperties
}
style={sharedMotionStyle as React.CSSProperties | undefined}
>
<div
className={tcls(
'pointer-events-none absolute overflow-visible',
shouldMirror ? '' : 'left-0',
'pointer-events-none absolute left-0 overflow-visible',
styles.arcMenuPath
)}
style={
{
...(shouldMirror
? {
'--arc-mirror-offset': '-345px',
'--spread-distance': '37px',
right: 'var(--arc-mirror-offset)',
}
: undefined),
...(shouldFlipVertical
? {
bottom: 'auto',
top: 'calc(var(--arc-height) / -2)',
transform: 'scaleY(-1)',
}
: undefined),
} as React.CSSProperties
}
ref={ref}
>
{items.map((item, index) => (
<ArcToolbarButton
index={index}
staggerIndex={closing ? index : items.length - 1 - index}
key={item.id}
mirrored={shouldMirror}
flippedVertical={shouldFlipVertical}
closing={closing}
staggerIndex={items.length - 1 - index}
key={item.icon}
{...item}
onClick={
item.isLabel
? undefined
: () => {
close();
item.onClick?.();
}
}
onClick={() => {
setOpen(false);
item.onClick?.();
}}
/>
))}
</div>
@@ -226,17 +127,13 @@ type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description?: string;
description: string;
onClick?: () => void;
isLabel?: boolean;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick' | 'isLabel'> & {
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
index: number;
staggerIndex?: number;
mirrored?: boolean;
flippedVertical?: boolean;
closing?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
@@ -246,41 +143,40 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
const {
index,
staggerIndex = index,
mirrored = false,
flippedVertical = false,
closing = false,
label,
disabled,
className,
onClick,
onClick = () => {},
icon,
iconClassName,
isLabel = false,
} = props;
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const Tag = isLabel ? 'div' : 'button';
const itemRotation = calculateRotation();
return (
<div className="pointer-events-none">
<Tag
{...(Tag === 'button' ? { type: 'button' as const } : {})}
onClick={onClick ? () => onClick() : undefined}
<button
type="button"
onClick={() => {
onClick();
}}
style={
{
'--from-offset-distance': fromOffset,
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: fromOffset,
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
offsetRotate: 'auto 90deg',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
} as React.CSSProperties
}
className={tcls(
@@ -289,61 +185,49 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
mirrored ? 'flex-row-reverse' : '',
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
styles.arcMenuItem,
className
)}
>
<div
className="flex items-center gap-2"
style={flippedVertical ? { transform: 'scaleY(-1)' } : undefined}
>
{isLabel ? (
<div className="flex items-center gap-2 rounded-lg bg-[rgba(79,139,255,0.1)] px-2 py-1 text-[#4F8BFF] backdrop-blur-sm">
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className="size-3.5 shrink-0"
/>
<span className="whitespace-nowrap font-normal text-sm">{label}</span>
</div>
) : (
<>
<div
className={tcls(
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls(
'size-3.5 shrink-0 group-hover:scale-110',
iconClassName
)}
/>
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
)}
>
{label}
</span>
</>
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
</div>
</Tag>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</div>
);
}
@@ -17,7 +17,7 @@
.arcMenuItem {
animation-name: hide-toolbar-arc-enter;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-fill-mode: forwards;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
@@ -28,101 +28,13 @@
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
}
to {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
}
}
@keyframes hide-toolbar-arc-exit {
from {
offset-distance: var(--target-offset-distance);
offset-rotate: auto var(--rotation-offset);
}
to {
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
}
.arcMenuItemExit {
animation-name: hide-toolbar-arc-exit;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.3s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
offset-path: border-box;
offset-anchor: 0% 0%;
offset-rotate: auto var(--rotation-offset, 0deg);
}
/* ── Arc label fade ── */
@keyframes arc-label-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes arc-label-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.arcLabelFadeIn {
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
}
.arcLabelFadeOut {
animation: arc-label-fade-out 0.2s ease-out forwards;
}
/* ── Glass effect for the toolbar pill ── */
.glassLayer {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
z-index: -1;
isolation: isolate;
}
.glassLayer::before {
content: '';
position: absolute;
inset: -50%;
pointer-events: none;
-webkit-backdrop-filter: blur(9px);
backdrop-filter: blur(9px);
filter: url(#glass-distortion);
}
.glassLayer::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: rgba(39, 39, 39, 0.55);
box-shadow:
inset 2px 2px 1px rgba(255, 255, 255, 0.08),
inset -1px -1px 1px rgba(255, 255, 255, 0.06);
}
:global(.dark) .glassLayer::after {
background: rgba(30, 30, 30, 0.6);
box-shadow:
inset 2px 2px 1px rgba(255, 255, 255, 0.05),
inset -1px -1px 1px rgba(255, 255, 255, 0.04);
}
@media (prefers-reduced-motion: reduce) {
.glassLayer::before {
filter: none;
transform: scale(1);
opacity: 1;
}
}
@@ -2,71 +2,39 @@
import {
AnimatePresence,
type MotionValue,
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
} from 'motion/react';
import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext';
import {
getStoredPosition,
getVisibilityHintDismissed,
setStoredPosition,
setVisibilityHintDismissed,
} from './utils';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { Tooltip } from '../primitives';
// import styles from './Toolbar.module.css';
import { getCopyVariants, toolbarEasings } from './transitions';
import { useMagnificationEffect } from './useMagnificationEffect';
const DEBUG = true;
const DURATION_LOGO_APPEARANCE = 2000;
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
const ToolbarDraggingContext = React.createContext(false);
interface ToolbarProps {
label: React.ReactNode;
children: React.ReactNode;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
export function Toolbar(props: ToolbarProps) {
const { children, minified, onMinifiedChange } = props;
const { children, label, minified, onMinifiedChange } = props;
const controls = useToolbarControls();
const [isReady, setIsReady] = React.useState(false);
const autoExpandTriggeredRef = React.useRef(false);
const constraintsRef = React.useRef<HTMLDivElement>(null);
const innerRef = React.useRef<HTMLDivElement>(null);
const isDraggingRef = React.useRef(false);
const [isDragging, setIsDragging] = React.useState(false);
const prevWidthRef = React.useRef<number | undefined>(undefined);
const [hintDismissed, setHintDismissed] = React.useState(() =>
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
);
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
// Restore saved drag position (synchronous read — no flash)
const storedPos = React.useMemo(
() => (typeof window !== 'undefined' ? getStoredPosition() : null),
[]
);
const x = useMotionValue(storedPos?.x ?? 0);
const y = useMotionValue(storedPos?.y ?? 0);
const savePosition = React.useCallback(() => {
setStoredPosition({ x: x.get(), y: y.get() });
}, [x, y]);
// Wait for page to be ready, then show the toolbar
React.useEffect(() => {
const handleLoad = () => {
@@ -112,432 +80,58 @@ export function Toolbar(props: ToolbarProps) {
React.useEffect(() => {
if (!minified) {
// Any manual expansion should stop the logo animation so the icon stays in its
// "settled" state once the toolbar is open.
// settled state once the toolbar is open.
setShouldAnimateLogo(false);
}
}, [minified]);
// Detect pinning state based on actual viewport position
const debugRef = React.useRef<HTMLDivElement>(null);
const debugLeftEdgeRef = React.useRef<HTMLDivElement>(null);
const debugRightEdgeRef = React.useRef<HTMLDivElement>(null);
const debugArrowRef = React.useRef<HTMLDivElement>(null);
const lastDecisionRef = React.useRef<'left' | 'right' | 'center'>('center');
const computePinState = React.useCallback(
(overrideWidth?: number) => {
if (!innerRef.current) return 'center' as const;
const rect = innerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const edgeThreshold = 80; // px from viewport edge
// When overrideWidth is provided (e.g. prevWidth), reconstruct where the
// edges WERE before the resize. The center stays the same because x hasn't
// been compensated yet and flexbox centering is width-independent.
let left = rect.left;
let right = rect.right;
if (overrideWidth !== undefined) {
const center = (rect.left + rect.right) / 2;
left = center - overrideWidth / 2;
right = center + overrideWidth / 2;
}
const pin =
right > viewportWidth - edgeThreshold
? ('right' as const)
: left < edgeThreshold
? ('left' as const)
: ('center' as const);
// Update debug overlays directly via DOM to avoid React re-render lag
if (DEBUG) {
// Text overlay
if (debugRef.current) {
const el = debugRef.current;
el.textContent = '';
const decisionColor = lastDecisionRef.current === 'center' ? '#facc15' : '#4ade80';
const liveColor = pin === 'center' ? '#facc15' : '#4ade80';
const preInfo =
overrideWidth !== undefined
? ` | pre: L${Math.round(left)} R${Math.round(right)}`
: '';
el.innerHTML = `decision: <span style="color:${decisionColor}">${lastDecisionRef.current}</span> | live: <span style="color:${liveColor}">${pin}</span> | x: ${Math.round(x.get())} y: ${Math.round(y.get())} | rect: L${Math.round(rect.left)} R${Math.round(rect.right)} (vw: ${viewportWidth})${preInfo}`;
}
// Edge anchor indicators on the toolbar pill (track live state)
if (debugLeftEdgeRef.current) {
debugLeftEdgeRef.current.style.backgroundColor =
pin === 'left' ? '#4ade80' : '#ffffff20';
debugLeftEdgeRef.current.style.boxShadow =
pin === 'left' ? '0 0 8px #4ade80' : 'none';
}
if (debugRightEdgeRef.current) {
debugRightEdgeRef.current.style.backgroundColor =
pin === 'right' ? '#4ade80' : '#ffffff20';
debugRightEdgeRef.current.style.boxShadow =
pin === 'right' ? '0 0 8px #4ade80' : 'none';
}
// Arrow showing expansion direction (tracks live state so it
// updates as you drag, showing what WOULD happen on click)
if (debugArrowRef.current) {
debugArrowRef.current.textContent =
pin === 'left'
? '→'
: pin === 'right'
? '←'
: '↔';
debugArrowRef.current.style.color =
pin === 'center' ? '#facc15' : '#4ade80';
}
}
return pin;
},
[x, y]
);
// Update debug display reactively when x/y change (direct DOM updates, no re-renders)
React.useEffect(() => {
if (!DEBUG) return;
const unsubX = x.on('change', () => computePinState());
const unsubY = y.on('change', () => computePinState());
// Initial computation
computePinState();
return () => {
unsubX();
unsubY();
};
}, [x, y, computePinState]);
// Compensate drag x position when the toolbar width changes (expand/collapse) so the
// pinned edge stays anchored. Uses viewport-aware edge detection instead of a fixed
// x-threshold so it works correctly at any viewport width.
React.useLayoutEffect(() => {
if (!innerRef.current) return;
const newWidth = innerRef.current.offsetWidth;
const prevWidth = prevWidthRef.current;
if (prevWidth !== undefined && prevWidth !== newWidth) {
const delta = newWidth - prevWidth;
const currentX = x.get();
const currentY = y.get();
const expanding = newWidth > prevWidth;
// Grab the raw post-resize rect for logging
const rawRect = innerRef.current.getBoundingClientRect();
const center = (rawRect.left + rawRect.right) / 2;
const preResizeLeft = center - prevWidth / 2;
const preResizeRight = center + prevWidth / 2;
const viewportWidth = window.innerWidth;
const edgeThreshold = 80;
// Use prevWidth to reconstruct where the edges WERE before the resize.
const pin = computePinState(prevWidth);
lastDecisionRef.current = pin;
// Refresh debug overlay so "decision" label updates immediately
computePinState(prevWidth);
let compensation = 0;
if (pin === 'right') {
compensation = -delta / 2;
} else if (pin === 'left') {
compensation = delta / 2;
}
// eslint-disable-next-line no-console -- temporary debug logging
console.warn(
`[Toolbar Pin] ${expanding ? 'EXPAND' : 'COLLAPSE'}\n` +
` Action: ${expanding ? 'minified → expanded' : 'expanded → minified'}\n` +
` Width: ${prevWidth}px → ${newWidth}px (delta: ${delta}px)\n` +
` Motion: x=${Math.round(currentX)}, y=${Math.round(currentY)}\n` +
` Viewport: ${viewportWidth}px, edgeThreshold: ${edgeThreshold}px\n` +
` Post-resize rect (raw): L=${Math.round(rawRect.left)} R=${Math.round(rawRect.right)}\n` +
` Pre-resize rect (reconstructed): L=${Math.round(preResizeLeft)} R=${Math.round(preResizeRight)}\n` +
` Center: ${Math.round(center)}\n` +
` Pin check: left=${Math.round(preResizeLeft)} < ${edgeThreshold}? ${preResizeLeft < edgeThreshold} | right=${Math.round(preResizeRight)} > ${viewportWidth - edgeThreshold}? ${preResizeRight > viewportWidth - edgeThreshold}\n` +
` → Decision: ${pin}\n` +
` → Compensation: ${compensation}px (x: ${Math.round(currentX)}${Math.round(currentX + compensation)})`
);
if (compensation !== 0) {
animate(x, currentX + compensation, {
type: 'spring',
stiffness: 200,
damping: 30,
mass: 1,
}).then(savePosition);
}
}
prevWidthRef.current = newWidth;
}, [minified, x, savePosition, computePinState]);
// Don't render anything until page is ready
if (!isReady) {
return null;
}
return (
<ToolbarDraggingContext.Provider value={isDragging}>
{/* Hidden SVG filter for glass distortion */}
<svg aria-hidden="true" className="pointer-events-none fixed size-0">
<defs>
<filter id="glass-distortion">
<feTurbulence
type="fractalNoise"
baseFrequency="0.016 0.012"
numOctaves={3}
seed={39}
result="noise"
/>
<feGaussianBlur in="noise" stdDeviation={6} result="softNoise" />
<feDisplacementMap
in="SourceGraphic"
in2="softNoise"
scale={160}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</defs>
</svg>
<div
ref={constraintsRef}
className="pointer-events-none fixed inset-2 z-40 flex items-end justify-center"
>
<motion.div
drag
dragConstraints={constraintsRef}
dragElastic={0.04}
dragTransition={{
power: 0.2,
timeConstant: 200,
bounceStiffness: 800,
bounceDamping: 60,
}}
style={{ x, y }}
onDragStart={() => {
isDraggingRef.current = true;
setIsDragging(true);
}}
onDragEnd={() => {
setIsDragging(false);
requestAnimationFrame(() => {
isDraggingRef.current = false;
});
}}
onDragTransitionEnd={savePosition}
className="pointer-events-auto relative w-auto max-w-xl cursor-grab active:cursor-grabbing"
>
{/* Visibility peek label — rendered as a sibling before the pill so it paints behind it */}
{!hintDismissed && (
<motion.div
initial={false}
animate={{
y: !minified && !isDragging ? 0 : 10,
opacity: !minified && !isDragging ? 1 : 0,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 25,
}}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3"
style={{ paddingBlock: '2px' }}
>
<span className="whitespace-nowrap text-[11px] text-neutral-9">
Only visible to your GitBook organization
</span>
<button
type="button"
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
onClick={(e) => {
e.stopPropagation();
setVisibilityHintDismissed();
setHintDismissed(true);
}}
>
Dismiss
</button>
</motion.div>
)}
<AnimatePresence mode="wait">
<motion.div
ref={innerRef}
onClick={() => {
if (isDraggingRef.current) return;
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
'relative',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[var(--toolbar-bg)]'
)}
style={
{
'--toolbar-bg': '#1f1d1b',
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
zIndex: 1, // Ensure pill stacks above the peek label sibling
} as React.CSSProperties
<Tooltip label={label}>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
>
{/* Glass effect layer (disabled for now) */}
{/* <div className={styles.glassLayer} /> */}
{/* Debug: anchor edge indicators */}
{DEBUG && (
<>
<div
ref={debugLeftEdgeRef}
style={{
position: 'absolute',
left: -1,
top: '15%',
bottom: '15%',
width: 3,
borderRadius: 2,
backgroundColor: '#ffffff20',
transition: 'background-color 0.2s, box-shadow 0.2s',
zIndex: 10,
}}
/>
<div
ref={debugRightEdgeRef}
style={{
position: 'absolute',
right: -1,
top: '15%',
bottom: '15%',
width: 3,
borderRadius: 2,
backgroundColor: '#ffffff20',
transition: 'background-color 0.2s, box-shadow 0.2s',
zIndex: 10,
}}
/>
<div
ref={debugArrowRef}
style={{
position: 'absolute',
top: -20,
left: '50%',
transform: 'translateX(-50%)',
fontSize: 16,
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 10,
}}
/>
</>
)}
{/* Logo — double-click to minimize without opening the arc menu */}
<motion.div
layout
onDoubleClick={(e) => {
if (isDraggingRef.current || minified) return;
e.stopPropagation();
onMinifiedChange(true);
}}
>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
{!minified ? children : null}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'backdrop-blur-sm',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
)}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
</motion.div>
</AnimatePresence>
</motion.div>
{/* Debug overlay for pinning state — updated via DOM ref to avoid re-render lag */}
{DEBUG && (
<>
<div
ref={debugRef}
className="pointer-events-none whitespace-nowrap rounded bg-black/80 px-2 py-1 font-mono text-[10px] text-white"
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 9999,
}}
/>
{/* Viewport edge threshold zones */}
<div
style={{
position: 'fixed',
left: 0,
top: 0,
width: 80,
height: '100vh',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
borderRight: '1px dashed rgba(74, 222, 128, 0.3)',
pointerEvents: 'none',
zIndex: 39,
}}
>
<span
style={{
position: 'absolute',
bottom: 80,
right: 4,
fontSize: 9,
color: 'rgba(74, 222, 128, 0.5)',
writingMode: 'vertical-rl',
fontFamily: 'monospace',
}}
>
pin-left zone (80px)
</span>
</div>
<div
style={{
position: 'fixed',
right: 0,
top: 0,
width: 80,
height: '100vh',
backgroundColor: 'rgba(74, 222, 128, 0.06)',
borderLeft: '1px dashed rgba(74, 222, 128, 0.3)',
pointerEvents: 'none',
zIndex: 39,
}}
>
<span
style={{
position: 'absolute',
bottom: 80,
left: 4,
fontSize: 9,
color: 'rgba(74, 222, 128, 0.5)',
writingMode: 'vertical-rl',
fontFamily: 'monospace',
}}
>
pin-right zone (80px)
</span>
</div>
</>
)}
</div>
</ToolbarDraggingContext.Provider>
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
);
}
@@ -603,53 +197,63 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
children,
} = props;
const reduceMotion = useReducedMotion();
const isDragging = React.useContext(ToolbarDraggingContext);
const anchor = (
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
'text-tint-7 hover:text-tint-1',
'dark:text-tint-12',
disabled ? 'cursor-not-allowed opacity-50' : '',
'bg-[var(--toolbar-bg)]',
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
/>
</motion.a>
);
return (
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
{children ? children : null}
{title && !isDragging ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
<Tooltip label={title}>
<motion.a
href={href}
onClick={onClick}
target="_blank"
rel="noopener noreferrer"
style={
reduceMotion
? undefined
: {
scale: motionValues?.scale,
x: motionValues?.x,
transformOrigin: 'bottom center',
zIndex: motionValues?.scale ? 10 : 'auto',
...style,
}
}
transition={{
type: 'spring',
stiffness: 400,
damping: 30,
}}
className={tcls(
'toolbar-button',
className,
'flex',
'relative',
'items-center',
'justify-center',
'gap-1',
'text-sm',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'cursor-pointer',
'transition-colors',
'size-8',
disabled ? 'cursor-not-allowed opacity-50' : '',
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
<Icon
icon={icon}
iconStyle={IconStyle.Solid}
className={tcls(
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
iconClassName
)}
/>
</motion.a>
</Tooltip>
</motion.div>
);
});
@@ -735,7 +339,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
@@ -1,142 +0,0 @@
'use client';
import React from 'react';
import { useLanguage } from '@/intl/client';
import { AnimatePresence, motion } from 'motion/react';
type DateFormat = 'relative' | 'weekday' | 'full';
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
/**
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
*/
export function ToolbarDate(props: { value: string }) {
const { value } = props;
const language = useLanguage();
const [now, setNow] = React.useState<number>(Date.now());
const [formatIndex, setFormatIndex] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(
() => {
setNow(Date.now());
},
30 * 60 * 1000
);
return () => {
clearInterval(interval);
};
}, []);
const date = new Date(value);
const format = DATE_FORMATS[formatIndex] as DateFormat;
const formatted = React.useMemo(() => {
return formatDateValue(format, language.locale, now, date);
}, [format, language.locale, now, date]);
return (
<div className="inline-flex items-center gap-1">
{/* Vertical dots indicating cycleable formats */}
<div className="flex flex-col items-center gap-[1px]">
{DATE_FORMATS.map((fmt, i) => (
<motion.span
key={fmt}
animate={{
opacity: i === formatIndex ? 1 : 0.3,
scale: i === formatIndex ? 1 : 0.75,
}}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="block size-[3px] rounded-full bg-current"
/>
))}
</div>
<time
data-visual-test="transparent"
suppressHydrationWarning={true}
dateTime={value}
onClick={(e) => {
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
}
}}
className="relative inline-flex min-w-24 cursor-pointer overflow-hidden font-semibold transition-colors hover:text-white"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={formatIndex}
initial={{ y: '-100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
{formatted}
</motion.span>
</AnimatePresence>
</time>
</div>
);
}
function formatDateValue(format: DateFormat, locale: string, now: number, date: Date): string {
switch (format) {
case 'relative':
return formatRelative(locale, now - date.getTime());
case 'weekday':
return date.toLocaleDateString(locale, {
weekday: 'long',
month: 'short',
day: 'numeric',
year: 'numeric',
});
case 'full':
return date.toLocaleString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
}
function formatRelative(locale: string, diff: number) {
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
return `${days} days ago`;
}
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'long' });
if (diff < 60 * 60 * 1000) {
const minutes = Math.floor(diff / 60 / 1000);
return rtf.format(-minutes, 'minute');
}
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / 60 / 60 / 1000);
return rtf.format(-hours, 'hour');
}
if (diff < 30 * 24 * 60 * 60 * 1000) {
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
return rtf.format(-days, 'day');
}
if (diff < 365 * 24 * 60 * 60 * 1000) {
const months = Math.floor(diff / 30 / 24 / 60 / 60 / 1000);
return rtf.format(-months, 'month');
}
const years = Math.floor(diff / 365 / 24 / 60 / 60 / 1000);
return rtf.format(-years, 'year');
}
@@ -11,8 +11,6 @@ import {
const STORAGE_KEY = 'gitbook_toolbar_closed';
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
const SESSION_POSITION_KEY = 'gitbook_toolbar_position';
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
type SessionHideReason = 'session' | 'persistent';
@@ -59,35 +57,6 @@ export const setStoredMinified = (value: boolean) => {
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
};
/**
* Retrieve the last drag position from session storage. Returns `null` when no position has been
* stored, meaning the toolbar should use its default center-bottom placement.
*/
export const getStoredPosition = (): { x: number; y: number } | null => {
return getSessionStorageItem<{ x: number; y: number } | null>(SESSION_POSITION_KEY, null);
};
/**
* Persist the current drag position for the ongoing session.
*/
export const setStoredPosition = (position: { x: number; y: number }) => {
setSessionStorageItem(SESSION_POSITION_KEY, position);
};
/**
* Check whether the user has dismissed the "only you can see this" hint.
*/
export const getVisibilityHintDismissed = (): boolean => {
return getLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
};
/**
* Persist that the user dismissed the visibility hint.
*/
export const setVisibilityHintDismissed = () => {
setLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
};
interface UseToolbarVisibilityOptions {
onPersistentClose?: () => void;
onSessionClose?: () => void;
@@ -7,18 +7,18 @@ import {
import { Card, type CardProps } from '@/components/primitives';
import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props;
const { document, block, context, style } = props;
const resolved = context.contentContext
? await resolveContentRef(block.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['text-xl', 'text-tint'],
})
@@ -84,6 +84,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
ancestorBlocks: props.ancestorBlocks,
});
// Determine if this block should expand to full width or use readable width
// Text blocks (paragraphs, headings, lists) use a readable max-width
// Visual blocks (code, tables, images, etc.) expand to full width
const isFullWidthBlock = FULL_WIDTH_BLOCKS.includes(node.type);
return (
<Block
key={node.key || `${node.type}-${index}`}
@@ -94,7 +99,12 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
? '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',
isFullWidthBlock && 'page-width-wide:max-w-full',
// In OpenAPI mode, all blocks expand to full width
// In full-width mode, content is capped at 64rem (1024px) and centered
'layout-openapi:max-w-full',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -1,6 +1,6 @@
'use client';
import type { DocumentBlockCode } from '@gitbook/api';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive';
@@ -8,18 +8,19 @@ import { useInViewportListener } from '@/components/hooks/useInViewportListener'
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button, ToggleChevron } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight';
import type { HighlightTheme, RenderedInline } from './highlight';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
themes?: CustomizationThemedCodeTheme;
};
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
@@ -29,7 +30,7 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables } = props;
const { block, mode, style, inlines, inlineExprVariables, themes } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
@@ -40,17 +41,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
visitorClaims,
variables: inlineExprVariables,
});
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
const plainTheme = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
[block, inlines, evaluateInlineExpression, themes]
);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [theme, setTheme] = useState<null | HighlightTheme>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted.
useEffect(() => {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block, themes));
}, [block, themes]);
// When user scrolls, we need to wait for the scroll to finish before running the highlight
const isScrollingRef = useRef(false);
@@ -99,14 +100,16 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
if (cancelled) {
return;
}
highlight(block, inlines, { evaluateInlineExpression, themes }).then(
(theme) => {
if (cancelled) {
return;
}
setLines(lines);
setHighlighting(false);
});
setTheme(theme);
setHighlighting(false);
}
);
});
}
@@ -116,12 +119,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}
// Otherwise if the block is not in viewport, we reset to the plain lines
setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]);
setTheme(null);
}, [isInViewport, block, inlines, evaluateInlineExpression, themes]);
const expandable = block.data.expandable;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const numberOfLinesOfCode = theme?.lines.length ?? plainTheme.lines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
@@ -136,16 +139,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
theme={theme ?? plainTheme}
id={codeBlockBodyId}
/>
);
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
theme={theme ?? plainTheme}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
style={style}
>
{renderer}
</CodeBlockExpandable>
@@ -156,21 +160,25 @@ export function ClientCodeBlock(props: ClientBlockProps) {
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
theme: HighlightTheme;
collapsedLineCount: number;
controls?: string;
style?: ClassValue;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const { children, controls, theme, collapsedLineCount, style } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls('group/codeblock-expandable relative', style)}
data-follow-color-scheme="true"
aria-expanded={isExpanded}
>
<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-[""]'
!isExpanded
? '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden'
: ''
)}
style={
{
@@ -181,7 +189,7 @@ function CodeBlockExpandable(props: {
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<div className="pointer-events-none absolute bottom-2 flex w-full justify-center">
<Button
icon={<ToggleChevron open={isExpanded} />}
size="xsmall"
@@ -194,7 +202,7 @@ function CodeBlockExpandable(props: {
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
: t(language, 'code_block_collapsed', theme.lines.length)}
</Button>
</div>
</div>
@@ -1,6 +1,10 @@
import * as React from 'react';
import type { DocumentBlockCode } from '@gitbook/api';
import type {
CustomizationThemedCodeTheme,
DocumentBlockCode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document';
@@ -13,8 +17,21 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
/**
* Render a code block, can be client-side or server-side.
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = props;
export async function CodeBlock(
props: BlockProps<DocumentBlockCode> & {
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}
) {
const {
block,
document,
style,
isEstimatedOffscreen,
context,
themeKey = 'default',
themes: providedThemes,
} = props;
const inlines = getInlines(block);
let hasInlineExpression = false;
@@ -50,10 +67,19 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body };
});
// Get code themes from customization
const themes =
providedThemes ??
(context.contentContext && 'customization' in context.contentContext
? context.contentContext.customization.styling.codeTheme[themeKey]
: undefined);
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} />;
const theme = await highlight(block, richInlines, {
themes: themes,
});
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
}
const variables = context.contentContext
@@ -74,6 +100,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
inlines={richInlines}
inlineExprVariables={variables}
mode={context.mode}
themes={themes}
/>
</React.Suspense>
);
@@ -7,10 +7,10 @@ import { tcls } from '@/lib/tailwind';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
theme: HighlightTheme;
'aria-busy'?: boolean;
id?: string;
};
@@ -22,7 +22,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -30,15 +30,35 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const id = useId();
const codeId = props.id || id;
/* Shiki returns a color + CSS variables in a single string, which isn't supported in React.
So we parse the foreground & background into objects that can be passed into React's `style` prop. */
const bg = parseShikiColorString(theme.bg);
const fg = parseShikiColorString(theme.fg);
return (
<div
ref={ref}
aria-busy={ariaBusy}
className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)}
className={tcls(
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
style
)}
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? (
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
<div
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
{title}
</div>
) : null}
@@ -49,23 +69,32 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base p-2 [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
'rounded-md straight-corners:rounded-xs shadow-xs',
title && 'rounded-ss-none'
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base p-2 text-tint-strong [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs shadow-xs',
title && 'rounded-ss-none!'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
<code
id={codeId}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
)}
>
{lines.map((line, index) => (
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === lines.length - 1}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
@@ -77,13 +106,35 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
function CodeHighlightLine(props: {
line: HighlightLine;
bg?: ShikiColorDefinition;
fg?: ShikiColorDefinition;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
const { line, isLast, withLineNumbers, bg, fg } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers && <span className="highlight-line-number" />}
<span
className={tcls('highlight-line', line.highlighted && 'highlighted')}
style={
line.highlighted
? {
backgroundColor: bg?.color,
...bg?.vars,
}
: undefined
}
>
{withLineNumbers && (
<span
className="highlight-line-number"
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
/>
)}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -112,13 +163,53 @@ function CodeHighlightToken(props: { token: HighlightToken }) {
return token.content;
}
case 'shiki': {
if (!token.token.color) {
if (!token.token.htmlStyle) {
return token.token.content;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
return <span style={token.token.htmlStyle}>{token.token.content}</span>;
}
default:
assertNever(token);
}
}
type ShikiColorDefinition = {
color: string | undefined;
vars: Record<string, string> | undefined;
};
/**
* Parse Shiki color string format into separate color and CSS variables.
*
* Shiki returns colors in a format that combines a default color with CSS variables:
* "defaultColor;--shiki-light:value1;--shiki-dark:value2"
*
* React does not parse these strings as valid CSS because they contain values & properties in one.
* We split these so React can apply them separately via the style prop.
*/
function parseShikiColorString(
originalColor: string | undefined
): ShikiColorDefinition | undefined {
if (!originalColor) {
return undefined;
}
const parts = originalColor.split(';');
const color = parts[0];
const vars = parts.slice(1).reduce(
(acc, item) => {
const [key, value] = item.split(':');
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
return {
color,
vars: Object.keys(vars).length > 0 ? vars : undefined,
};
}
@@ -1,4 +1,8 @@
import type { JSONDocument } from '@gitbook/api';
import type {
CustomizationThemedCodeTheme,
JSONDocument,
SiteCustomizationSettings,
} from '@gitbook/api';
import { useId } from 'react';
import { CodeBlock } from './CodeBlock';
@@ -8,8 +12,13 @@ import { convertCodeStringToBlock } from './utils';
* Plain code block with syntax highlighting.
* For simplicity, this is just a wrapper around the CodeBlock component, emulating a document.
*/
export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props;
export function PlainCodeBlock(props: {
code: string;
syntax: string;
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, themeKey, themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -26,6 +35,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
context={{
mode: 'default',
}}
themeKey={themeKey}
themes={themes}
block={block}
ancestorBlocks={[]}
// We optimize perf by default
@@ -0,0 +1,832 @@
import type { ThemeRegistration } from 'shiki/core';
export const customThemes: Record<string, ThemeRegistration> = {
'default-light': createTheme({
name: 'default-light',
type: 'light',
bg: 'rgb(var(--tint-2))',
fg: 'rgb(var(--tint-12))',
colors: {
basic: {
comment: 'rgb(var(--neutral-9))',
string: 'rgb(var(--success-10))',
constant: 'rgb(var(--warning-10))',
escape: 'rgb(var(--success-10))',
},
keyword: {
default: 'rgb(var(--danger-10))',
operator: 'rgb(var(--tint-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--warning-10))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--danger-10))',
parameter: 'rgb(var(--warning-10))',
},
function: {
default: 'rgb(var(--primary-10))',
support: 'rgb(var(--primary-10))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--warning-10))',
other: 'rgb(var(--tint-12))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--warning-10))',
attributeId: 'rgb(var(--primary-10))',
label: 'rgb(var(--danger-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--success-10))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--warning-10))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-10))',
deleted: 'rgb(var(--danger-10))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-11))',
preprocessor: 'rgb(var(--danger-10))',
default: 'rgb(var(--tint-11))',
invalid: 'rgb(var(--danger-10))',
},
},
}),
'default-dark': createTheme({
name: 'default-dark',
type: 'dark',
bg: 'rgb(var(--tint-2))',
fg: 'rgb(var(--tint-12))',
colors: {
basic: {
comment: 'rgb(var(--neutral-9))',
string: 'rgb(var(--success-11))',
constant: 'rgb(var(--warning-11))',
escape: 'rgb(var(--success-11))',
},
keyword: {
default: 'rgb(var(--danger-11))',
operator: 'rgb(var(--tint-11))',
regexpOperator: 'rgb(var(--primary-11))',
regexpQuantifier: 'rgb(var(--warning-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--danger-11))',
parameter: 'rgb(var(--warning-11))',
},
function: {
default: 'rgb(var(--primary-11))',
support: 'rgb(var(--primary-11))',
},
type: {
custom: 'rgb(var(--primary-11))',
primitive: 'rgb(var(--warning-11))',
other: 'rgb(var(--tint-12))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--warning-11))',
attributeId: 'rgb(var(--primary-11))',
label: 'rgb(var(--danger-11))',
},
markup: {
default: 'rgb(var(--primary-11))',
code: 'rgb(var(--success-11))',
link: 'rgb(var(--primary-11))',
linkUrl: 'rgb(var(--warning-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-12))',
preprocessor: 'rgb(var(--danger-11))',
default: 'rgb(var(--tint-12))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
'monochrome-light': createTheme({
name: 'monochrome-light',
type: 'light',
bg: 'rgb(var(--tint-2))',
fg: 'rgb(var(--tint-12))',
colors: {
basic: {
comment: 'rgb(var(--tint-9))',
string: 'rgb(var(--primary-12))',
constant: 'rgb(var(--primary-11))',
escape: 'rgb(var(--tint-12))',
},
keyword: {
default: 'rgb(var(--primary-10))',
operator: 'rgb(var(--primary-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--primary-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--primary-10))',
parameter: 'rgb(var(--tint-11))',
},
function: {
default: 'rgb(var(--primary-9))',
support: 'rgb(var(--primary-9))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--primary-11))',
other: 'rgb(var(--primary-10))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--primary-11))',
attributeId: 'rgb(var(--primary-9))',
label: 'rgb(var(--primary-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--tint-12))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--primary-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-10))',
preprocessor: 'rgb(var(--primary-10))',
default: 'rgb(var(--primary-11))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
'monochrome-dark': createTheme({
name: 'monochrome-dark',
type: 'dark',
bg: 'rgb(var(--tint-2))',
fg: 'rgb(var(--tint-12))',
colors: {
basic: {
comment: 'rgb(var(--tint-9))',
string: 'rgb(var(--primary-12))',
constant: 'rgb(var(--primary-11))',
escape: 'rgb(var(--tint-12))',
},
keyword: {
default: 'rgb(var(--primary-11))',
operator: 'rgb(var(--primary-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--primary-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--primary-10))',
parameter: 'rgb(var(--tint-9))',
},
function: {
default: 'rgb(var(--primary-9))',
support: 'rgb(var(--primary-9))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--primary-11))',
other: 'rgb(var(--primary-10))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--primary-11))',
attributeId: 'rgb(var(--primary-9))',
label: 'rgb(var(--primary-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--tint-12))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--primary-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-10))',
preprocessor: 'rgb(var(--primary-10))',
default: 'rgb(var(--primary-11))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
};
/**
* Color category mappings for syntax highlighting.
* Organized into logical groups for better readability.
*/
export type ColorCategories = {
/** Basic syntax elements */
basic: {
/** Comments (//, /*, #, etc.) */
comment: string;
/** String literals ("...", '...', `...`) */
string: string;
/** Constants: numeric, language constants (true, false, null), character constants */
constant: string;
/** Escape sequences in strings (\n, \t, etc.) */
escape: string;
};
/** Keywords and operators */
keyword: {
/** Control flow (if, else, for, while, return), storage (const, let, var, function, class) */
default: string;
/** Operators (+, -, *, /, =, ==, etc.) */
operator: string;
/** Regexp operators (|, ^, $) */
regexpOperator: string;
/** Regexp quantifiers (*, +, ?, {n}) */
regexpQuantifier: string;
};
/** Variables and parameters */
variable: {
/** Regular variables */
default: string;
/** Language variables (this, self, super) */
language: string;
/** Function/method parameters */
parameter: string;
};
/** Functions */
function: {
/** User-defined function/method invocations */
default: string;
/** Built-in/library support functions */
support: string;
};
/** Types and classes */
type: {
/** Custom type names (DocumentBlockCode, HighlightLine, etc.) */
custom: string;
/** Primitive types (number, boolean, string, etc.) */
primitive: string;
/** Other types (support.type, support.class, namespaces) */
other: string;
/** Class meta contexts (meta.class) */
classMeta: string;
};
/** Entities (tags, attributes, labels) */
entity: {
/** HTML/XML tags and attributes */
tag: string;
/** Attribute IDs (entity.other.attribute-name.id) */
attributeId: string;
/** Labels (entity.name.label) */
label: string;
};
/** Markup elements */
markup: {
/** Markup elements (bold, italic, headings, lists, quotes, etc.) */
default: string;
/** Inline code in markup */
code: string;
/** Links in markup */
link: string;
/** Link URLs in markup */
linkUrl: string;
/** Separators (meta.separator) */
separator: string;
};
/** Diff highlighting */
diff: {
/** Inserted lines */
inserted: string;
/** Deleted lines */
deleted: string;
/** Changed lines */
changed: string;
};
/** Other syntax elements */
other: {
/** Punctuation (., ,, ;, :, {}, [], (), etc.) */
punctuation: string;
/** Preprocessor directives (#include, #define, etc.) */
preprocessor: string;
/** Other/miscellaneous elements */
default: string;
/** Invalid syntax/errors */
invalid: string;
};
};
/**
* Creates a Shiki theme registration from semantic color categories.
*
* Maps our semantic color categories (comment, keyword, function, etc.) to Shiki's TextMate scopes.
* TextMate scopes are hierarchical selectors (e.g., "keyword.control", "variable.parameter") that
* Shiki uses to identify different syntax elements. This abstraction allows us to define themes
* using semantic categories rather than low-level scope patterns.
*/
function createTheme(options: {
name: string;
type: 'light' | 'dark';
bg: string;
fg: string;
colors: ColorCategories;
}): ThemeRegistration {
const { name, type, bg, fg, colors } = options;
return {
name,
type,
bg,
fg,
settings: [
// Comments
{
scope: ['comment'],
settings: {
foreground: colors.basic.comment,
},
},
// Constants: language constants (true, false, null, etc.)
{
scope: ['constant.language'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: numeric
{
scope: [
'constant.numeric',
'variable.other.enummember',
'keyword.operator.plus.exponent',
'keyword.operator.minus.exponent',
'keyword.other.unit',
'constant.sha.git-rebase',
],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: regexp
{
scope: ['constant.regexp'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: character
{
scope: ['constant.character', 'constant.other.option'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: escape sequences
{
scope: ['constant.character.escape'],
settings: {
foreground: colors.basic.escape,
},
},
// Constants: other
{
scope: [
'constant.other',
'constant.other.color',
'constant.other.symbol',
'constant.other.placeholder',
'constant.other.character-class.regexp',
'constant.character.set.regexp',
'constant.character.character-class.regexp',
'constant.other.character-class.set.regexp',
],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: variable constants
{
scope: ['variable.other.constant'],
settings: {
foreground: colors.basic.constant,
},
},
// Strings
{
scope: [
'string',
'meta.embedded.assembly',
'string.tag',
'string.value',
'string.regexp',
'meta.preprocessor.string',
'support.constant.property-value',
'support.constant.font-name',
'support.constant.media-type',
'support.constant.media',
'constant.other.color.rgb-value',
'constant.other.rgb-value',
'support.constant.color',
'punctuation.definition.group.regexp',
'punctuation.definition.group.assertion.regexp',
'punctuation.definition.character-class.regexp',
'punctuation.character.set.begin.regexp',
'punctuation.character.set.end.regexp',
'keyword.operator.negation.regexp',
'support.other.parenthesis.regexp',
],
settings: {
foreground: colors.basic.string,
},
},
// String expressions (template literals, interpolated)
// Only color the punctuation, not the expression content
{
scope: [
'punctuation.definition.template-expression.begin',
'punctuation.definition.template-expression.end',
'punctuation.section.embedded',
],
settings: {
foreground: colors.variable.parameter,
},
},
// Keywords: control flow
{
scope: [
'keyword.control',
'source.cpp keyword.operator.new',
'keyword.operator.delete',
'keyword.other.using',
'keyword.other.directive.using',
'keyword.other.operator',
'entity.name.operator',
'keyword.operator.new',
'keyword.operator.expression',
'keyword.operator.cast',
'keyword.operator.sizeof',
'keyword.operator.alignof',
'keyword.operator.typeid',
'keyword.operator.alignas',
'keyword.operator.instanceof',
'keyword.operator.logical.python',
'keyword.operator.wordlike',
],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: storage (const, let, var, function, class, etc.)
{
scope: [
'storage',
'storage.type',
'storage.modifier',
'keyword.operator.noexcept',
'storage.type.numeric.go',
'storage.type.byte.go',
'storage.type.boolean.go',
'storage.type.string.go',
'storage.type.uintptr.go',
'storage.type.error.go',
'storage.type.rune.go',
'storage.type.cs',
'storage.type.generic.cs',
'storage.type.modifier.cs',
'storage.type.variable.cs',
'storage.type.annotation.java',
'storage.type.generic.java',
'storage.type.java',
'storage.type.object.array.java',
'storage.type.primitive.array.java',
'storage.type.primitive.java',
'storage.type.token.java',
'storage.type.groovy',
'storage.type.annotation.groovy',
'storage.type.parameters.groovy',
'storage.type.generic.groovy',
'storage.type.object.array.groovy',
'storage.type.primitive.array.groovy',
'storage.type.primitive.groovy',
],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: general
{
scope: ['keyword'],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: operators (default)
{
scope: ['keyword.operator'],
settings: {
foreground: colors.keyword.operator,
},
},
// Keywords: regexp operators
{
scope: ['keyword.operator.or.regexp', 'keyword.control.anchor.regexp'],
settings: {
foreground: colors.keyword.regexpOperator,
},
},
// Keywords: regexp quantifiers
{
scope: ['keyword.operator.quantifier.regexp'],
settings: {
foreground: colors.keyword.regexpQuantifier,
},
},
// Variable language (this, self, super, etc.)
{
scope: ['variable.language'],
settings: {
foreground: colors.variable.language,
},
},
// Parameters
{
scope: ['variable.parameter'],
settings: {
foreground: colors.variable.parameter,
},
},
// Variables (regular variables, not parameters)
{
scope: [
'variable',
'meta.definition.variable.name',
'support.variable',
'entity.name.variable',
'meta.object-literal.key',
],
settings: {
foreground: colors.variable.default,
},
},
// Functions: invocations (calls) - user-defined functions
{
scope: [
'meta.function-call',
'meta.method-call',
'entity.name.function',
'entity.name.method',
'support.constant.handlebars',
'source.powershell variable.other.member',
'entity.name.operator.custom-literal',
'keyword.other.special-method',
],
settings: {
foreground: colors.function.default,
},
},
// Support functions: built-in/library functions
{
scope: ['support.function', 'support.function.git-rebase'],
settings: {
foreground: colors.function.support,
},
},
// Types: custom type names (DocumentBlockCode, HighlightLine, etc.) - must come first
{
scope: ['entity.name.type', 'entity.name.class', 'entity.other.inherited-class'],
settings: {
foreground: colors.type.custom,
},
},
// Types: primitive (number, boolean, string, etc.) - must come before support.type
{
scope: [
'support.type.primitive',
'storage.type.primitive',
'support.type.builtin',
'entity.name.type.primitive',
'support.type',
],
settings: {
foreground: colors.type.primitive,
},
},
// Types: other (support.class, etc.)
{
scope: [
'support.class',
'entity.name.namespace',
'entity.other.attribute',
'entity.name.scope-resolution',
'meta.type.cast.expr',
'meta.type.new.expr',
'support.constant.math',
'support.constant.json',
'punctuation.separator.namespace.ruby',
],
settings: {
foreground: colors.type.other,
},
},
// Class meta contexts
{
scope: ['meta.class'],
settings: {
foreground: colors.type.classMeta,
},
},
// DOM constants and namespace references (ReactDOM, window.document, etc.)
{
scope: [
'support.constant.dom',
'variable.other.object',
'variable.other.object.property',
'entity.name.class.jsx',
'support.class.builtin',
],
settings: {
foreground: colors.variable.parameter,
},
},
// Entity names: tags, attributes
{
scope: [
'entity.name.tag',
'entity.other.attribute-name',
'support.type.vendored.property-name',
'support.type.property-name',
'source.css variable',
'source.coffee.embedded',
'meta.structure.dictionary.key.python',
],
settings: {
foreground: colors.entity.tag,
},
},
// Attribute IDs (typically styled like functions)
{
scope: ['entity.other.attribute-name.id', 'punctuation.definition.entity'],
settings: {
foreground: colors.entity.attributeId,
},
},
// Punctuation
{
scope: [
'punctuation',
'punctuation.definition.tag',
'punctuation.definition.quote.begin.markdown',
'punctuation.definition.list.begin.markdown',
'punctuation.section.embedded.begin.php',
'punctuation.section.embedded.end.php',
],
settings: {
foreground: colors.other.punctuation,
},
},
// Links: link text
{
scope: [
'markup.underline.link',
'string.other.link',
'punctuation.definition.string.end.markdown',
'punctuation.definition.string.begin.markdown',
],
settings: {
foreground: colors.markup.link,
},
},
// Links: link URLs
{
scope: ['meta.link'],
settings: {
foreground: colors.markup.linkUrl,
},
},
// Preprocessor
{
scope: [
'meta.preprocessor',
'entity.name.function.preprocessor',
'meta.preprocessor.numeric',
],
settings: {
foreground: colors.other.preprocessor,
},
},
// Diff: inserted
{
scope: ['markup.inserted', 'markup.inserted.diff'],
settings: {
foreground: colors.diff.inserted,
},
},
// Diff: deleted
{
scope: ['markup.deleted', 'markup.deleted.diff'],
settings: {
foreground: colors.diff.deleted,
},
},
// Diff: changed
{
scope: ['markup.changed', 'markup.changed.diff', 'meta.diff.header'],
settings: {
foreground: colors.diff.changed,
},
},
// Markup: bold (with bold font style)
{
scope: ['markup.bold', 'punctuation.definition.bold'],
settings: {
foreground: colors.markup.default,
fontStyle: 'bold',
},
},
// Markup: italic (with italic font style)
{
scope: ['markup.italic', 'punctuation.definition.italic'],
settings: {
foreground: colors.markup.default,
fontStyle: 'italic',
},
},
// Markup: inline code
{
scope: ['markup.raw.inline'],
settings: {
foreground: colors.markup.code,
},
},
// Markup: headings, lists, quotes, underline, strikethrough
{
scope: [
'markup.heading',
'markup.heading punctuation.definition.heading',
'entity.name.section',
'header',
'markup.list',
'markup.quote',
'markup.underline',
'markup.strikethrough',
],
settings: {
foreground: colors.markup.default,
},
},
// Separator
{
scope: ['meta.separator'],
settings: {
background: colors.markup.separator,
foreground: fg,
},
},
// Labels
{
scope: ['entity.name.label'],
settings: {
foreground: colors.entity.label,
},
},
// Other
{
scope: [
'meta.embedded',
'source.groovy.embedded',
'string meta.image.inline.markdown',
'variable.legacy.builtin.python',
'storage.modifier.import.java',
'variable.language.wildcard.java',
'storage.modifier.package.java',
],
settings: {
foreground: colors.other.default,
},
},
// Invalid
{
scope: ['invalid'],
settings: {
foreground: colors.other.invalid,
},
},
],
};
}
@@ -8,7 +8,8 @@ async function highlightWithInlines(block: DocumentBlockCode) {
inline,
body: null,
}));
return highlight(block, inlines);
const result = await highlight(block, inlines);
return result.lines;
}
it('should parse plain code', async () => {
@@ -224,7 +225,19 @@ it('should parse code with an inline on a single line', async () => {
{
type: 'shiki',
token: {
content: '"Hello World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -375,7 +388,19 @@ it('should parse code with an inline on a multiple line', async () => {
{
type: 'shiki',
token: {
content: '"Hello World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -631,7 +656,13 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: '.world',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'world',
},
},
{
@@ -645,7 +676,13 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: ');',
content: ')',
},
},
{
type: 'shiki',
token: {
content: ';',
},
},
],
@@ -699,7 +736,13 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.log',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
},
},
{
@@ -711,7 +754,19 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"Hello"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -734,7 +789,13 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.log',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
},
},
{
@@ -746,7 +807,19 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -1,20 +1,39 @@
import type {
DocumentBlockCode,
DocumentBlockCodeLine,
DocumentInlineAnnotation,
import {
CustomizationCodeTheme,
type CustomizationThemedCodeTheme,
type DocumentBlockCode,
type DocumentBlockCodeLine,
type DocumentInlineAnnotation,
} from '@gitbook/api';
import {
type ThemeRegistrationAny,
type ThemedToken,
createCssVariablesTheme,
createSingletonShorthands,
createdBundledHighlighter,
} from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { type BundledLanguage, bundledLanguages } from 'shiki/langs';
import { bundledThemes } from 'shiki/themes';
import { nullIfNever } from '@/lib/typescript';
import { customThemes } from './customThemes';
import { plainHighlight } from './plain-highlight';
export const DEFAULT_THEMES: CustomizationThemedCodeTheme = {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
};
export type HighlightTheme = {
bg?: string;
fg?: string;
themes: {
light: ThemeRegistrationAny;
dark: ThemeRegistrationAny;
};
lines: HighlightLine[];
};
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -36,12 +55,12 @@ export type RenderedInline = {
const isSafari =
typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const theme = createCssVariablesTheme();
// Merge bundled Shiki themes with our custom themes so both are available to the highlighter
const { getSingletonHighlighter } = createSingletonShorthands(
createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
themes: { ...bundledThemes, ...customThemes },
engine: () => createJavaScriptRegexEngine({ forgiving: true, target: 'ES2018' }),
})
);
@@ -49,12 +68,15 @@ const { getSingletonHighlighter } = createSingletonShorthands(
/**
* Preload the highlighter for a code block.
*/
export async function preloadHighlight(block: DocumentBlockCode) {
export async function preloadHighlight(
block: DocumentBlockCode,
themes: CustomizationThemedCodeTheme = DEFAULT_THEMES
) {
const langName = getBlockLang(block);
if (langName) {
await getSingletonHighlighter({
langs: [langName],
themes: [theme],
themes: [themes.light, themes.dark],
});
}
}
@@ -67,8 +89,9 @@ export async function highlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): Promise<HighlightLine[]> {
): Promise<HighlightTheme> {
const langName = getBlockLang(block);
if (!langName || (isSafari && ['powershell', 'cpp'].includes(langName))) {
@@ -80,13 +103,20 @@ export async function highlight(
return plainHighlight(block, inlines, options);
}
const themes = options?.themes ?? DEFAULT_THEMES;
const code = getPlainCodeBlock(block, undefined, options);
const highlighter = await getSingletonHighlighter({
langs: [langName],
themes: [theme],
themes: [themes.light, themes.dark],
});
const resolvedThemes = {
light: highlighter.getTheme(themes.light),
dark: highlighter.getTheme(themes.dark),
};
let tokenizeMaxLineLength = 400;
// In some cases, people will use unindented code blocks with a single line.
// In this case, we can safely increase the max line length to avoid not highlighting the code.
@@ -94,38 +124,47 @@ export async function highlight(
tokenizeMaxLineLength = 5000;
}
const lines = highlighter.codeToTokensBase(code, {
const result = highlighter.codeToTokens(code, {
lang: langName,
theme,
themes: resolvedThemes,
// Shiki's light-dark() CSS function provides different colors for light/dark modes based on the resolved themes
defaultColor: 'light-dark()',
tokenizeMaxLineLength,
});
const lines = result.tokens;
let currentIndex = 0;
return lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
return {
bg: result.bg,
fg: result.fg,
themes: resolvedThemes,
lines: lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
currentIndex += 1; // for the \n
currentIndex += 1; // for the \n
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
});
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
}),
};
}
/**
@@ -1,8 +1,9 @@
import type { DocumentBlockCode } from '@gitbook/api';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
@@ -12,42 +13,59 @@ export function plainHighlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): HighlightLine[] {
): HighlightTheme {
const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
};
}
// Resolve theme objects: first check bundled Shiki themes, then custom themes, finally fallback to type-only
// The type-only fallback ensures HighlightTheme always has valid theme objects even when theme names don't match
const themes = {
light: bundledThemesInfo.find((theme) => theme.id === options?.themes?.light) ??
Object.values(customThemes).find((theme) => theme.name === options?.themes?.light) ?? {
type: 'light',
},
dark: bundledThemesInfo.find((theme) => theme.id === options?.themes?.dark) ??
Object.values(customThemes).find((theme) => theme.name === options?.themes?.dark) ?? {
type: 'dark',
},
};
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
return {
themes: themes,
lines: block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
},
],
};
});
};
}
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
});
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
};
});
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
}),
};
}
@@ -71,6 +71,8 @@ export function DocumentView(
style,
// Preserve adjacent whitespace and new lines.
'whitespace-pre-wrap',
// In OpenAPI mode, add left padding for sidebar offset
'layout-openapi:pl-12',
]}
context={context}
isOffscreen={isOffscreen}
@@ -85,7 +87,7 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
const { document, blockStyle } = props;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 layout-openapi:pl-12">
{document.nodes.map((block) => (
<BlockSkeleton
key={block.key!}
@@ -95,6 +97,10 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
block.data && 'fullWidth' in block.data && block.data.fullWidth
? 'max-w-screen-xl'
: 'max-w-3xl',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
blockStyle,
]}
/>
@@ -1,6 +1,6 @@
import type { DocumentBlockDrawing } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { Image } from '../utils';
import type { BlockProps } from './Block';
@@ -8,11 +8,11 @@ import { Caption } from './Caption';
import { imageBlockSizes } from './Images';
export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
const { block, context } = props;
const { document, block, context } = props;
const resolved =
block.data.ref && context.contentContext
? await resolveContentRef(block.data.ref, context.contentContext)
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null;
if (!resolved) {
return null;
@@ -2,7 +2,7 @@ import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { t } from '@/intl/translate';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { getSpaceLanguage } from '@/intl/server';
import { Button, Link } from '../primitives';
@@ -12,13 +12,17 @@ import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props;
const { document, block, context } = props;
if (!context.contentContext) {
return null;
}
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
const contentRef = await resolveContentRefInDocument(
document,
block.data.ref,
context.contentContext
);
const file = contentRef?.file;
if (!file) {
@@ -1,7 +1,7 @@
import type { DocumentBlockImage, DocumentBlockImages, JSONDocument, Length } from '@gitbook/api';
import { Image, type ImageResponsiveSize } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
@@ -81,12 +81,14 @@ async function ImageBlock(props: {
isEstimatedOffscreen: boolean;
withFrame?: boolean;
}) {
const { block, context, isEstimatedOffscreen, withFrame } = props;
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
context.contentContext
? resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null,
block.data.refDark && context.contentContext
? resolveContentRef(block.data.refDark, context.contentContext)
? resolveContentRefInDocument(document, block.data.refDark, context.contentContext)
: null,
]);
@@ -1,7 +1,7 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { useAI, useAIChatController, useAIChatState } from '../AI';
import { useSearch } from '../Search';
import { useSetSearchState } from '../Search';
import { Button, type ButtonProps, Input } from '../primitives';
export function InlineActionButton(
@@ -12,7 +12,7 @@ export function InlineActionButton(
const { assistants } = useAI();
const chatController = useAIChatController();
const chatState = useAIChatState();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const language = useLanguage();
const handleSubmit = (value: string) => {
@@ -1,4 +1,4 @@
import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button, type ButtonProps } from '../primitives';
@@ -45,13 +45,13 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
export async function InlineLinkButton(
props: InlineProps<api.DocumentInlineButton> & { buttonProps: ButtonProps }
) {
const { inline, context, buttonProps } = props;
const { document, inline, context, buttonProps } = props;
if (!('ref' in inline.data)) return;
const resolved =
context.contentContext && inline.data.ref
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null;
const href =
@@ -2,20 +2,22 @@ import type { GitBookBaseContext } from '@/lib/context';
import type { DocumentInlineImage } from '@gitbook/api';
import assertNever from 'assert-never';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Image } from '../utils';
import type { InlineProps } from './Inline';
export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
const { inline, context, ancestorInlines } = props;
const { document, inline, context, ancestorInlines } = props;
const { size = 'original' } = inline.data;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(inline.data.ref, context.contentContext) : null,
context.contentContext
? resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null,
inline.data.refDark && context.contentContext
? resolveContentRef(inline.data.refDark, context.contentContext)
? resolveContentRefInDocument(document, inline.data.refDark, context.contentContext)
: null,
]);
@@ -4,8 +4,8 @@ import { getSpaceLanguage, tString } from '@/intl/server';
import { type TranslationLanguage, languages } from '@/intl/translations';
import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import { Icon } from '@gitbook/icons';
import { StyledLink } from '../../primitives';
@@ -15,10 +15,10 @@ import { NotFoundRefHoverCard } from '../NotFoundRefHoverCard';
import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const { document, inline, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, {
// We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link.
resolveAnchorText: false,
})
@@ -182,8 +182,16 @@ function getOrderedListItemPrefixContent(input: {
}): string {
const { parent, block } = input;
const start = parent.data.start ?? 1;
const index = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const value = index + start;
let index: number;
// If the block has a key, use it to find the index. Otherwise, use the object reference.
if (block.key) {
index = parent.nodes.findIndex((node) => node.key === block.key);
} else {
index = parent.nodes.findIndex((node) => node === block);
}
const value = index >= 0 ? index + start : Math.max(start, 1);
switch (input.depth % 3) {
// Use numbers
case 0: {
@@ -1,15 +1,15 @@
import { type DocumentInlineMention, SiteInsightsLinkPosition } from '@gitbook/api';
import { StyledLink } from '@/components/primitives';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import type { InlineProps } from './Inline';
export async function Mention(props: InlineProps<DocumentInlineMention>) {
const { inline, context } = props;
const { document, inline, context } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
@@ -42,7 +42,17 @@ export function getOpenAPIContext(args: {
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
},
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
{...codeProps}
themeKey="openapi"
themes={
context && 'customization' in context
? context.customization.styling.codeTheme.openapi
: undefined
}
/>
),
renderDocument: (documentProps) => (
<DocumentView
document={documentProps.document as JSONDocument}
@@ -527,7 +527,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
@apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
@apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
}
.openapi-response-examples-panel {
@@ -541,7 +541,9 @@
.openapi-panel pre,
.openapi-codesample pre,
.openapi-response-examples pre {
@apply bg-transparent border-none rounded-none shrink shadow-none;
@apply border-none rounded-none shrink shadow-none;
/* Override the code block's background to transparent so it matches with the header and footer */
@apply bg-transparent!
}
.openapi-panel-heading {
@@ -1,12 +1,13 @@
import type { DocumentBlockReusableContent } from '@gitbook/api';
import { getDataOrNull } from '@/lib/data';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import type { BlockProps } from './Block';
import { UnwrappedBlocks } from './Blocks';
// TODO-DEREF: Remove this once we have rolled out the new reusable content deref in the API.
export async function ReusableContent(props: BlockProps<DocumentBlockReusableContent>) {
const { block, context, ancestorBlocks } = props;
const { document, block, context, ancestorBlocks } = props;
if (!context.contentContext) {
throw new Error('Expected a content context to render a reusable content block');
@@ -16,7 +17,7 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
: context.contentContext.dataFetcher;
const resolved = await resolveContentRef(block.data.ref, {
const resolved = await resolveContentRefInDocument(document, block.data.ref, {
...context.contentContext,
dataFetcher,
});
@@ -30,7 +31,7 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
return null;
}
const document = await getDataOrNull(
const reusableContentDocument = await getDataOrNull(
dataFetcher.getRevisionReusableContentDocument({
spaceId: reusableContent.context.space.id,
revisionId: reusableContent.context.revisionId,
@@ -38,14 +39,14 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
})
);
if (!document) {
if (!reusableContentDocument) {
return null;
}
return (
<UnwrappedBlocks
nodes={document.nodes}
document={document}
nodes={reusableContentDocument.nodes}
document={reusableContentDocument}
ancestorBlocks={[...ancestorBlocks, block]}
context={{
...context,
@@ -32,7 +32,16 @@ export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
})();
return (
<div className={tcls('mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8', style)}>
<div
className={tcls(
'mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
style
)}
>
<div className="relative select-none">
<div
className={tcls(
@@ -1,6 +1,6 @@
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import {
CardsImageObjectFit,
@@ -18,7 +18,7 @@ export async function RecordCard(
record: TableRecordKV;
}
) {
const { view, record, context, block, isOffscreen } = props;
const { view, record, context, block, isOffscreen, document } = props;
const { dark, light } = getRecordCardCovers(record[1], view);
const targetRef = view.targetDefinition
@@ -27,13 +27,13 @@ export async function RecordCard(
const [lightCover, darkCover, target] = await Promise.all([
light.contentRef && context.contentContext
? resolveContentRef(light.contentRef, context.contentContext)
? resolveContentRefInDocument(document, light.contentRef, context.contentContext)
: null,
dark.contentRef && context.contentContext
? resolveContentRef(dark.contentRef, context.contentContext)
? resolveContentRefInDocument(document, dark.contentRef, context.contentContext)
: null,
targetRef && context.contentContext
? resolveContentRef(targetRef, context.contentContext)
? resolveContentRefInDocument(document, targetRef, context.contentContext)
: null,
]);
@@ -12,7 +12,7 @@ import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
import type { BlockProps } from '../Block';
@@ -176,7 +176,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
const files = await Promise.all(
value.map((fileId) =>
context.contentContext
? resolveContentRef(
? resolveContentRefInDocument(
document,
{
kind: 'file',
file: fileId,
@@ -250,7 +251,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const resolved =
value && context.contentContext
? await resolveContentRef(value, context.contentContext, {
? await resolveContentRefInDocument(document, value, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['mr-2', 'text-tint-subtle'],
})
@@ -293,7 +294,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
user: userId,
};
const resolved = context.contentContext
? await resolveContentRef(contentRef, context.contentContext)
? await resolveContentRefInDocument(
document,
contentRef,
context.contentContext
)
: null;
if (!resolved) {
return null;
@@ -366,7 +371,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const image = context.contentContext
? await resolveContentRef(
? await resolveContentRefInDocument(
document,
'ref' in value ? value.ref : value,
context.contentContext
)
@@ -224,7 +224,7 @@ const TabPanel = memo(function TabPanel(props: {
aria-labelledby={getTabButtonId(tab.id)}
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
>
<div className="p-4" hidden={!isActive}>
<div className={tcls('p-4')} hidden={!isActive}>
{tab.body}
</div>
</div>
@@ -60,8 +60,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
// Remove padding-top from headings when they're the first child (similar to column-first-of-type pattern)
style="[&>*:first-child]:!pt-0 flex-1 space-y-4"
style="[&>*:first-child]:!pt-0 flex flex-1 flex-col [&>*+*]:mt-5"
/>
</div>
);
@@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind';
import { ThemeToggler } from '../ThemeToggler';
import { CONTAINER_STYLE } from '../layout';
import { FooterLinksGroup } from './FooterLinksGroup';
import { SocialLink } from './SocialLink';
const FOOTER_COLUMNS = 4;
@@ -20,8 +21,10 @@ export function Footer(props: { context: GitBookSiteContext }) {
const hasGroups = groupCount > 0;
const hasCopyright = customization.footer.copyright;
const hasThemeToggle = customization.themes.toggeable;
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
const mobileOnly = !hasLogo && !hasGroups && !hasCopyright && hasThemeToggle;
const mobileOnly =
!hasLogo && !hasGroups && !hasCopyright && !socialLinks.length && hasThemeToggle;
return (
<footer
@@ -45,16 +48,12 @@ export function Footer(props: { context: GitBookSiteContext }) {
>
<div
className={tcls(
'mx-auto flex @xs:grid @4xl:max-w-none! max-w-3xl site-width-wide:max-w-screen-2xl flex-col justify-between gap-12',
'mx-auto flex @xs:grid @4xl:max-w-none! layout-full-width:max-w-screen-2xl max-w-3xl flex-col justify-between gap-12',
'grid-cols-[auto_auto]',
'@4xl:grid-cols-[18rem_minmax(auto,48rem)_auto]',
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
'@4xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
'@7xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
'@4xl:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
'@7xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
'@4xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
'@7xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
'@4xl:layout-full-width:grid-cols-[minmax(auto,90rem)_auto]',
'@7xl:layout-full-width:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
)}
>
{
@@ -112,10 +111,22 @@ export function Footer(props: { context: GitBookSiteContext }) {
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'@4xl:page-has-toc:col-span-1 @7xl:page-no-toc:col-span-1 col-span-2 @4xl:page-has-toc:col-start-2 @7xl:page-no-toc:col-start-2'
'col-span-2',
'@4xl:layout-default:col-span-1',
'@4xl:layout-default:col-start-2',
'@4xl:layout-openapi:col-span-1',
'@4xl:layout-openapi:col-start-2',
'@7xl:layout-full-width:col-span-1',
'@7xl:layout-full-width:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl @xl:flex-row flex-col @xl:gap-6 gap-10">
<div
className={tcls(
'mx-auto flex max-w-3xl flex-col gap-10',
'layout-full-width:max-w-screen-2xl',
'@xl:flex-row @xl:gap-6'
)}
>
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
@@ -137,6 +148,21 @@ export function Footer(props: { context: GitBookSiteContext }) {
) : null
}
{
// Social Links
socialLinks.length > 0 ? (
<div className="col-span-full flex w-full grow items-center justify-center gap-2">
{socialLinks.map((account) => (
<SocialLink
key={`${account.platform}-${account.handle}`}
account={account}
target={customization.externalLinks.target}
/>
))}
</div>
) : null
}
{
// Legal
customization.footer.copyright ? (
@@ -0,0 +1,104 @@
import {
type SiteExternalLinksTarget,
type SiteSocialAccount,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives';
type SocialPlatformData = {
label: string;
icon: IconName;
/** The href to the social platform. `$handle` will be replaced with the account handle. */
href: string;
};
const SOCIAL_PLATFORMS: Record<SiteSocialAccountPlatform, SocialPlatformData> = {
[SiteSocialAccountPlatform.Twitter]: {
label: 'X/Twitter',
icon: 'x-twitter',
href: 'https://x.com/$handle',
},
[SiteSocialAccountPlatform.Instagram]: {
label: 'Instagram',
icon: 'instagram',
href: 'https://instagram.com/$handle',
},
[SiteSocialAccountPlatform.Facebook]: {
label: 'Facebook',
icon: 'facebook',
href: 'https://facebook.com/$handle',
},
[SiteSocialAccountPlatform.Linkedin]: {
label: 'LinkedIn',
icon: 'linkedin',
href: 'https://linkedin.com/$handle',
},
[SiteSocialAccountPlatform.Github]: {
label: 'GitHub',
icon: 'github',
href: 'https://github.com/$handle',
},
[SiteSocialAccountPlatform.Discord]: {
label: 'Discord',
icon: 'discord',
href: 'https://discord.com/$handle',
},
[SiteSocialAccountPlatform.Slack]: {
label: 'Slack',
icon: 'slack',
href: 'https://join.slack.com/t/$handle',
},
[SiteSocialAccountPlatform.Youtube]: {
label: 'YouTube',
icon: 'youtube',
href: 'https://youtube.com/@$handle',
},
[SiteSocialAccountPlatform.Tiktok]: {
label: 'TikTok',
icon: 'tiktok',
href: 'https://tiktok.com/@$handle',
},
[SiteSocialAccountPlatform.Reddit]: {
label: 'Reddit',
icon: 'reddit',
href: 'https://reddit.com/@$handle',
},
[SiteSocialAccountPlatform.Bluesky]: {
label: 'Bluesky',
icon: 'bluesky',
href: 'https://bsky.app/profile/$handle',
},
[SiteSocialAccountPlatform.Mastodon]: {
label: 'Mastodon',
icon: 'mastodon',
href: 'https://mastodon.social/@$handle',
},
[SiteSocialAccountPlatform.Threads]: {
label: 'Threads',
icon: 'threads',
href: 'https://threads.net/@$handle',
},
[SiteSocialAccountPlatform.Medium]: {
label: 'Medium',
icon: 'medium',
href: 'https://medium.com/@$handle',
},
};
export function SocialLink(props: { account: SiteSocialAccount; target: SiteExternalLinksTarget }) {
const { account, target } = props;
const platform = SOCIAL_PLATFORMS[account.platform];
return (
<Button
iconOnly
label={platform.label}
href={platform.href.replace('$handle', account.handle)}
icon={platform.icon}
variant="blank"
size="large"
target={target}
/>
);
}
@@ -105,7 +105,7 @@ export function Header(props: {
'hover:theme-bold:bg-header-link/3',
variants.generic.length > 1
? 'lg:hidden'
: 'page-no-toc:hidden lg:hidden'
: 'layout-full-width:hidden lg:hidden'
)}
/>
<HeaderLogo context={context} />
@@ -36,20 +36,39 @@ export function PageAside(props: {
className={tcls(
'group/aside',
'order-last',
'hidden',
'max-w-0',
'pt-8',
'pb-4',
'opacity-0',
'xl:flex',
// Hide by default
'hidden',
// Show on xl screens for default layout (normal sidebar)
'xl:layout-default:flex',
'xl:layout-default:max-w-56',
'xl:layout-default:opacity-11',
'xl:layout-default:ml-12',
// Show on xl screens for OpenAPI layout (floating overlay)
'xl:layout-openapi:flex',
'xl:layout-openapi:opacity-100',
'xl:layout-openapi:z-10',
'xl:layout-openapi:fixed',
'xl:layout-openapi:right-8',
'xl:layout-openapi:w-60',
'xl:layout-openapi:max-w-60',
'xl:layout-openapi:pb-8',
'xl:layout-openapi:pt-10',
'xl:layout-openapi:ml-0',
// Always hide for full-width layout
'layout-full-width:hidden!',
'overflow-hidden',
'xl:max-w-56',
'xl:opacity-11',
'xl:ml-12',
'max-w-0',
'opacity-0',
// Hide when chat is open
'xl:max-3xl:chat-open:hidden',
'xl:max-3xl:chat-open:max-w-0',
'xl:max-3xl:chat-open:opacity-0',
@@ -85,17 +104,7 @@ export function PageAside(props: {
// Client-side dynamic positioning (CSS vars applied by script)
'lg:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
'lg:[html[style*="--outline-height"]_&]:max-h-(--outline-height)!',
// When in api page mode, we display it as an overlay on non-large resolutions
'xl:max-2xl:page-api-block:z-10',
'xl:max-2xl:page-api-block:fixed',
'xl:max-2xl:page-api-block:right-8',
'xl:max-2xl:page-api-block:w-60',
'xl:max-2xl:page-api-block:max-w-60',
'xl:max-2xl:page-api-block:pb-8',
'xl:max-2xl:page-api-block:pt-10',
'xl:max-2xl:[body:has(.openapi-block):has(.page-has-ancestors)_&]:pt-6.5'
'lg:[html[style*="--outline-height"]_&]:max-h-(--outline-height)!'
)}
>
<div
@@ -104,17 +113,19 @@ export function PageAside(props: {
'min-w-56 shrink-0',
'overflow-hidden',
'w-full',
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
'xl:max-2xl:circular-corners:page-api-block:rounded-xl',
'xl:max-2xl:page-api-block:border',
'xl:max-2xl:page-api-block:border-tint',
'xl:max-2xl:page-api-block:bg-tint/9',
'xl:max-2xl:page-api-block:backdrop-blur-lg',
'xl:max-2xl:contrast-more:page-api-block:bg-tint',
'xl:max-2xl:page-api-block:hover:shadow-lg',
'xl:max-2xl:page-api-block:hover:shadow-tint-12/1',
'xl:max-2xl:dark:page-api-block:hover:shadow-tint-1/1',
'xl:max-2xl:page-api-block:not-hover:*:hidden'
// OpenAPI layout: floating card styles
'xl:layout-openapi:rounded-md',
'xl:layout-openapi:circular-corners:rounded-xl',
'xl:layout-openapi:border',
'xl:layout-openapi:border-tint',
'xl:layout-openapi:bg-tint/9',
'xl:layout-openapi:backdrop-blur-lg',
'xl:layout-openapi:contrast-more:bg-tint',
'xl:layout-openapi:hover:shadow-lg',
'xl:layout-openapi:hover:shadow-tint-12/1',
'xl:dark:layout-openapi:hover:shadow-tint-1/1',
'xl:layout-openapi:not-hover:*:hidden'
)}
>
<PageAsideHeader context={context} />
@@ -136,6 +147,10 @@ export function PageAside(props: {
);
}
/**
* Header for the aside that shows "ON THIS PAGE" label.
* Only visible in OpenAPI layout (floating overlay mode).
*/
function PageAsideHeader(props: { context: GitBookSiteContext }) {
const { context } = props;
const language = getSpaceLanguage(context);
@@ -144,7 +159,7 @@ function PageAsideHeader(props: { context: GitBookSiteContext }) {
<div
className={tcls(
'hidden',
'xl:max-2xl:page-api-block:flex!',
'xl:layout-openapi:flex!',
'text-xs',
'tracking-wide',
'font-semibold',
@@ -187,7 +202,8 @@ function PageAsideActions(props: {
className={tcls(
'flex flex-col gap-3',
'border-tint-subtle border-t first:border-none',
'sidebar-list-default:px-3 pt-5 first:pt-0 xl:max-2xl:page-api-block:p-5',
'sidebar-list-default:px-3 pt-5 first:pt-0',
'xl:layout-openapi:p-5',
'empty:hidden'
)}
>
@@ -209,7 +225,7 @@ async function PageAsideFooter(props: { context: GitBookSiteContext }) {
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col',
'bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'border-tint-subtle xl:layout-openapi:border-t xl:layout-openapi:p-2',
'pt-4'
)}
>
@@ -3,7 +3,13 @@ import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } f
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document';
import {
hasFullWidthBlock,
hasMoreThan,
hasOpenAPIBlock,
hasTopLevelBlock,
isNodeEmpty,
} from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -41,6 +47,7 @@ export function PageBody(props: {
const { customization } = context;
const contentFullWidth = document ? hasFullWidthBlock(document) : false;
const contentHasOpenAPI = document ? hasOpenAPIBlock(document) : false;
// Update blocks can only be at the top level of the document, so we optimize the check.
const contentHasUpdates = document
@@ -55,8 +62,7 @@ export function PageBody(props: {
LINK_PREVIEW_MAX_COUNT
)
: false;
const pageWidthWide = page.layout.width === 'wide';
const siteWidthWide = pageWidthWide || contentFullWidth;
const language = getSpaceLanguage(context);
const updatedAt = page.updatedAt ?? page.createdAt;
@@ -65,7 +71,20 @@ export function PageBody(props: {
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
).length > 0;
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
const hasTOC = page.layout.tableOfContents && hasVisibleTOCItems;
// Determine layout mode:
// 1. Full-width: No TOC
// 2. OpenAPI: Has TOC + (OpenAPI block OR wide property)
// 3. Default: Has TOC, no OpenAPI blocks, not wide
const layoutMode = !hasTOC
? 'layout-full-width'
: contentHasOpenAPI || page.layout.width === 'wide'
? 'layout-openapi'
: 'layout-default';
// Site-wide width only applies to full-width mode
const siteWidthWide = !hasTOC && (page.layout.width === 'wide' || contentFullWidth);
return (
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
@@ -73,15 +92,28 @@ export function PageBody(props: {
className={tcls(
'relative min-w-0 flex-1',
'max-w-screen-2xl py-8',
// In full-width layout, expand main to allow cover to go full width
'layout-full-width:max-w-full',
'layout-full-width:px-0',
// Allow words to break if they are too long.
'break-anywhere',
'@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
// Layout mode class for CSS variants
layoutMode,
// Keep existing classes for backward compatibility
hasTOC ? 'page-has-toc' : 'page-no-toc',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
pageHasToc ? 'page-has-toc' : 'page-no-toc'
// Only apply page-width-wide in full-width mode
!hasTOC && page.layout.width === 'wide'
? 'page-width-wide 3xl:px-8'
: 'page-width-default'
)}
>
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
<PreservePageLayout
siteWidthWide={siteWidthWide}
layoutMode={layoutMode}
hasTOC={hasTOC}
/>
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -128,7 +160,24 @@ export function PageBody(props: {
{
// TODO: after 25/07/2025, we can chage it to a true check as the cache will be updated
page.layout.metadata !== false ? (
<div className="mx-auto mt-6 page-api-block:ml-0 flex max-w-3xl page-full-width:max-w-screen-2xl flex-row flex-wrap items-center gap-4 text-tint contrast-more:text-tint-strong">
<div
className={tcls(
'mx-auto',
'mt-6',
'flex',
'max-w-3xl',
'flex-row',
'flex-wrap',
'items-center',
'gap-4',
'text-tint',
'contrast-more:text-tint-strong',
'layout-openapi:max-w-full',
'layout-openapi:pl-12',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto'
)}
>
{updatedAt ? (
<p className="mr-auto text-sm ">
{t(
@@ -79,6 +79,9 @@ export async function PageBodyBlankslate(props: {
'grid',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
// Expand to full width in OpenAPI and full-width layout modes
'layout-openapi:max-w-full',
'layout-full-width:max-w-full',
'w-full',
'mx-auto',
'gap-4',
@@ -83,22 +83,18 @@ export async function PageCover(props: {
data-full={String(as === 'full')}
className={tcls(
'overflow-hidden',
// Negative margin to balance the container padding
'-mx-4',
'flex',
'w-full',
'transition-all',
as === 'full'
? [
'sm:-mx-6',
'md:-mx-8',
!page.layout.tableOfContents &&
context.customization.header.preset !== 'none'
? [
'xl:-ml-76',
// Round the bottom corners once the page is wider than the image
'2xl:circular-corners:rounded-b-3xl 2xl:rounded-corners:rounded-b-xl',
]
: [
'lg:-ml-12',
// Round the bottom left corner once the sidebar is shown next to it
'lg:rounded-corners:rounded-bl-xl',
'lg:circular-corners:rounded-bl-3xl',
@@ -111,6 +107,11 @@ export async function PageCover(props: {
'sm:mx-auto',
'max-w-3xl ',
'page-width-wide:max-w-screen-2xl',
// In full-width layout, cover spans full viewport width
// Reset negative margins since main has no padding
'layout-full-width:mx-0',
'layout-full-width:max-w-full',
'layout-full-width:rounded-none',
'sm:rounded-corners:rounded-xl',
'sm:circular-corners:rounded-3xl',
'mb-8',
@@ -35,6 +35,11 @@ export async function PageFooterNavigation(props: {
'gap-2',
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-openapi:pl-12',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
'mx-auto',
'text-tint'
)}
@@ -52,11 +52,15 @@ export async function PageHeader(props: {
className={tcls(
'max-w-3xl',
'page-width-wide:max-w-screen-2xl',
// Expand in OpenAPI mode, cap at 64rem in full-width mode
'layout-openapi:max-w-full',
'layout-openapi:pl-12',
'layout-full-width:max-w-5xl',
'layout-full-width:mx-auto',
'mx-auto',
'mb-6',
'space-y-3',
'page-api-block:ml-0',
'page-api-block:max-w-full',
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
)}
>
@@ -1,20 +1,26 @@
'use client';
import * as React from 'react';
const LAYOUT_MODE_CLASSES = ['layout-default', 'layout-openapi', 'layout-full-width'] as const;
/**
* This component preserves the layout of the page while loading a new one.
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
* This approach is needed as page layout is done using CSS (`body:has(.layout-*)`),
* which becomes false while transitioning between the 2 page states:
*
* 1. Page 1 with full width block: `body:has(.site-width-wide)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.site-width-wide)` is false
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
* 1. Page 1 with layout mode: `body:has(.layout-default)` is true
* 2. Loading skeleton while transitioning to page 2: `body:has(.layout-default)` is false
* 3. Page 2 with layout mode: `body:has(.layout-default)` is true
*
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
* It also preserves the page TOC state (page-has-toc/page-no-toc) to prevent logo sizing issues during navigation.
*/
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
const { siteWidthWide, pageHasToc } = props;
export function PreservePageLayout(props: {
siteWidthWide: boolean;
layoutMode: (typeof LAYOUT_MODE_CLASSES)[number];
hasTOC: boolean;
}) {
const { siteWidthWide, layoutMode, hasTOC } = props;
React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions
@@ -24,20 +30,29 @@ export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc:
return;
}
// Clear all layout mode classes first
for (const cls of LAYOUT_MODE_CLASSES) {
header.classList.remove(cls);
}
// Add current layout mode
header.classList.add(layoutMode);
if (siteWidthWide) {
header.classList.add('site-width-wide');
} else {
header.classList.remove('site-width-wide');
}
if (pageHasToc) {
// Preserve page TOC state to prevent logo sizing issues during navigation
if (hasTOC) {
header.classList.add('page-has-toc');
header.classList.remove('page-no-toc');
} else {
header.classList.add('page-no-toc');
header.classList.remove('page-has-toc');
}
}, [siteWidthWide, pageHasToc]);
}, [siteWidthWide, layoutMode, hasTOC]);
return null;
}
@@ -34,6 +34,7 @@ import type { GitBookAnyContext } from '@/lib/context';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
import { defaultCustomization } from '@/lib/utils';
import { AnnouncementDismissedScript } from '../Announcement';
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
function preloadFont(fontData: FontData) {
if (fontData.type === 'custom') {
@@ -118,6 +119,8 @@ export async function CustomizationRootLayout(props: {
<link rel="privacy-policy" href={customization.privacyPolicy.url} />
) : null}
<OperatingSystemClassScript />
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
{monospaceFontData.type === 'custom' ? (
@@ -135,7 +138,7 @@ export async function CustomizationRootLayout(props: {
undefined
}
>{`
:root {
:root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
${generateColorVariable('primary', customization.styling.primaryColor.light)}
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
@@ -162,7 +165,7 @@ export async function CustomizationRootLayout(props: {
${generateColorVariable('success', successColor.light)}
}
.dark {
.dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
${generateColorVariable('primary', customization.styling.primaryColor.dark, { darkMode: true })}
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
// This function has to be pure because we do a `.toString()` on it.
function setOperatingSystemClass() {
if (typeof navigator === 'undefined') return;
const OS_MAC_CLASS = 'os-mac';
const platform = navigator.platform.toLowerCase();
const isMac = platform.includes('mac');
const root = document.documentElement;
if (isMac) {
root.classList.add(OS_MAC_CLASS);
} else {
root.classList.remove(OS_MAC_CLASS);
}
}
/**
* Inject a script to detect the OS and apply a class to the <html> element as early as possible.
* Bypasses react state to prevent flickering.
*/
export function OperatingSystemClassScript() {
React.useEffect(() => {
setOperatingSystemClass();
}, []);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${setOperatingSystemClass.toString()})()`,
}}
/>
);
}
@@ -296,88 +296,36 @@ html.announcement-hidden #announcement-banner {
@apply hidden;
}
/* Shiki theme */
:root {
--shiki-color-text: rgb(var(--tint-11));
--shiki-token-punctuation: rgb(var(--tint-11));
--shiki-token-comment: rgb(var(--neutral-9));
--shiki-token-link: rgb(var(--primary-10));
--shiki-token-constant: rgb(var(--warning-10));
--shiki-token-string: rgb(var(--warning-10));
--shiki-token-string-expression: rgb(var(--success-10));
--shiki-token-keyword: rgb(var(--danger-10));
--shiki-token-parameter: rgb(var(--warning-10));
--shiki-token-function: rgb(var(--primary-10));
--shiki-token-inserted: rgb(var(--success-10));
--shiki-token-deleted: rgb(var(--danger-10));
--shiki-token-changed: rgb(var(--tint-12));
}
@media (prefers-contrast: more) {
:root {
--shiki-color-text: rgb(var(--tint-12));
--shiki-token-punctuation: rgb(var(--tint-12));
--shiki-token-comment: rgb(var(--neutral-11));
--shiki-token-link: rgb(var(--primary-11));
--shiki-token-constant: rgb(var(--warning-11));
--shiki-token-string: rgb(var(--warning-11));
--shiki-token-string-expression: rgb(var(--success-11));
--shiki-token-keyword: rgb(var(--danger-11));
--shiki-token-parameter: rgb(var(--warning-11));
--shiki-token-function: rgb(var(--primary-11));
--shiki-token-inserted: rgb(var(--success-11));
--shiki-token-deleted: rgb(var(--danger-11));
--shiki-token-changed: rgb(var(--tint-12));
}
}
html.dark {
/* Override select colors to have more contrast */
--shiki-token-comment: rgb(var(--neutral-9));
--shiki-token-constant: rgb(var(--warning-11));
--shiki-token-string: rgb(var(--warning-11));
--shiki-token-string-expression: rgb(var(--success-11));
--shiki-token-keyword: rgb(var(--danger-11));
--shiki-token-parameter: rgb(var(--warning-11));
--shiki-token-function: rgb(var(--primary-11));
}
.code-monochrome {
--shiki-token-constant: rgb(var(--tint-11));
--shiki-token-string: rgb(var(--tint-12));
--shiki-token-string-expression: rgb(var(--tint-12));
--shiki-token-keyword: rgb(var(--primary-10));
--shiki-token-parameter: rgb(var(--tint-9));
--shiki-token-function: rgb(var(--primary-9));
}
html.dark.code-monochrome {
--shiki-token-constant: rgb(var(--tint-11));
--shiki-token-string: rgb(var(--tint-12));
--shiki-token-string-expression: rgb(var(--tint-12));
--shiki-token-keyword: rgb(var(--primary-11));
--shiki-token-parameter: rgb(var(--tint-10));
--shiki-token-function: rgb(var(--primary-10));
}
html.font-Lato {
/* Lato's default ligatures impact readability, so we turn them off */
font-variant-ligatures: no-common-ligatures;
}
/* Code blocks */
/* Shiki themes can define font styling (style, weight, decoration) via CSS variables.
* These variables are prefixed with --shiki-{mode}-{property} and allow themes to customize
* typography beyond just colors. We apply them here so code blocks respect theme font styling. */
.shiki, .shiki span {
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
html.dark .shiki,
html.dark .shiki span {
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
.highlight-line {
@apply grid grid-cols-subgrid col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-1 rounded-sm;
@apply text-tint-strong grid grid-cols-subgrid col-span-2 relative hover:invert-5 hover:z-1 rounded-sm;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
@apply bg-tint-active;
/* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
@apply bg-tint-base invert-10 hover:invert-15;
@apply first:rounded-t-md first:*:mt-1;
@apply last:rounded-b-md last:*:mb-1;
@apply rounded-none;
@@ -397,12 +345,8 @@ html.font-Lato {
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-linear-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
@apply before:text-tint before:content-[counter(line)];
.highlight-line.highlighted > & {
@apply before:text-tint bg-linear-to-r from-80% from-tint-active to-transparent;
}
@apply text-sm text-tint text-right pr-3.5 bg-tint-base rounded-l pl-2 sticky left-[-3px] mask-r-from-80%;
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
}
.highlight-line-content {
@@ -14,7 +14,7 @@ import { useTrackEvent } from '../Insights';
import { Button, Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext';
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch';
import { useSearchLink, useSetSearchState } from './useSearch';
export type SearchAskState =
| {
@@ -130,7 +130,7 @@ function TransitionAnswerBody(props: {
}, [answer]);
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return display ? (
<div className={tcls('flex w-full flex-col gap-4')}>
@@ -9,14 +9,14 @@ import { useAI } from '../AI';
import { AIChatButton } from '../AIChat';
import { useTrackEvent } from '../Insights';
import { useIsMobile } from '../hooks/useIsMobile';
import { Popover } from '../primitives';
import { Popover, useBodyLoaded } from '../primitives';
import { SearchAskAnswer } from './SearchAskAnswer';
import { useSearchAskState } from './SearchAskContext';
import { SearchAskProvider } from './SearchAskContext';
import { SearchInput } from './SearchInput';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearch } from './useSearch';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
@@ -60,12 +60,14 @@ export function SearchContainer({
}: SearchContainerProps) {
const { assistants, config } = useAI();
const [state, setSearchState] = useSearch();
const state = useSearchState();
const setSearchState = useSetSearchState();
const searchAsk = useSearchAskState();
const router = useRouter();
const trackEvent = useTrackEvent();
const resultsRef = useRef<SearchResultsRef>(null);
const searchInputRef = useRef<HTMLDivElement>(null);
const isLoaded = useBodyLoaded();
const isMobile = useIsMobile();
@@ -79,11 +81,13 @@ export function SearchContainer({
if (assistants.length === 0) return;
if (state?.ask === undefined || state?.ask === null) return;
initialRef.current = true;
// For simplicity we're only triggering the first assistant
assistants[0]?.open(state?.ask ?? undefined);
}, [state?.ask, assistants.length, assistants[0]?.open]);
// Because this is in the layout, we need to await for the body to be loaded.
if (isLoaded) {
assistants[0]?.open(state.ask ?? undefined);
initialRef.current = true;
}
}, [state?.ask, assistants.length, assistants[0]?.open, isLoaded]);
const onClose = React.useCallback(
async (to?: string) => {
@@ -68,9 +68,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
className="size-text-lg shrink-0 text-tint theme-bold:text-header-link/8"
/>
}
onChange={(event) => {
onChange(event.target.value);
}}
onValueChange={onChange}
value={value}
maxLength={512}
autoComplete="off"
@@ -3,7 +3,7 @@
import { t, tString, useLanguage } from '@/intl/client';
import type { SiteSection } from '@gitbook/api';
import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '../primitives';
import { useSearch } from './useSearch';
import { useSearchState, useSetSearchState } from './useSearch';
interface SearchScopeControlProps {
spaceTitle: string;
@@ -20,8 +20,7 @@ interface SearchScopeControlProps {
export function SearchScopeControl(props: SearchScopeControlProps) {
const { withVariants, withSections } = props;
const [state] = useSearch();
const language = useLanguage();
const state = useSearchState();
if (!state) {
return null;
@@ -33,7 +32,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
return (
<div className="flex items-center gap-1">
<span className="mr-1">{t(language, 'search_scope_title')}</span>
<SearchScopeTitle />
{withSections ? (
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
) : null}
@@ -45,11 +44,16 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
);
}
function SearchScopeTitle() {
const language = useLanguage();
return <span className="mr-1">{t(language, 'search_scope_title')}</span>;
}
function SearchScopeSectionControl(props: SearchScopeControlProps & { isExtended: boolean }) {
const { isExtended, section } = props;
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return (
<DropdownMenu
@@ -110,7 +114,7 @@ function SearchScopeVariantControl(props: SearchScopeControlProps & { isExtended
const { isExtended, spaceTitle, withSections } = props;
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return (
<DropdownMenu
@@ -1,6 +1,12 @@
'use client';
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
import {
type Values,
parseAsBoolean,
parseAsString,
parseAsStringLiteral,
useQueryStates,
} from 'nuqs';
import React from 'react';
import type { LinkProps } from '../primitives';
@@ -36,13 +42,24 @@ export type UpdateSearchState = (
update: React.SetStateAction<SearchState | null>
) => Promise<URLSearchParams>;
const SetSearchStateContext = React.createContext<UpdateSearchState | undefined>(undefined);
const SearchStateContext = React.createContext<SearchState | null | undefined>(undefined);
/**
* Context to share the search state updater so all consumers use the same instance.
* Normalize the raw state to handle legacy formats.
*/
export const SearchContext = React.createContext<{
state: SearchState | null;
setState: UpdateSearchState;
} | null>(null);
function normalizeRawState(values: Values<typeof keyMap>) {
// Convert legacy format: q=query&ask=true -> ask=query&q=null
if (values.ask === 'true' && values.q) {
return { ...values, q: null, ask: values.q };
}
// Handle legacy global=true
if (values.global === true) {
return { ...values, scope: 'all' as const, global: null };
}
return values;
}
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
const { children } = props;
@@ -52,38 +69,18 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
history: 'replace',
});
React.useEffect(() => {
// Handle legacy ask=true format by converting it to the new format
if (rawState?.ask === 'true' && rawState?.q) {
// Convert legacy format: q=query&ask=true -> ask=query&q=null
setRawState({
q: null,
ask: rawState.q,
});
}
// Handle legacy global=true
if (rawState?.global === true) {
setRawState({
scope: 'all',
global: null, // Remove the legacy parameter
});
}
}, [rawState, setRawState]);
// Local UI state for the popover open/close (not in URL)
const [open, setIsOpen] = React.useState(() => {
return rawState?.q !== null;
});
const [open, setIsOpen] = React.useState(() => normalizeRawState(rawState).q !== null);
const state = React.useMemo<SearchState | null>(() => {
if (rawState === null || (rawState.q === null && rawState.ask === null)) {
const normalized = normalizeRawState(rawState);
if (normalized.q === null && normalized.ask === null) {
return null;
}
return {
query: rawState.q,
ask: rawState.ask,
scope: rawState.scope,
query: normalized.q,
ask: normalized.ask,
scope: normalized.scope,
open,
};
}, [rawState, open]);
@@ -116,18 +113,29 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
[setRawState]
);
return <SearchContext.Provider value={{ state, setState }}>{children}</SearchContext.Provider>;
return (
<SearchStateContext.Provider value={state}>
<SetSearchStateContext.Provider value={setState}>
{children}
</SetSearchStateContext.Provider>
</SearchStateContext.Provider>
);
}
/**
* Hook to access the current search query and update it.
*/
export function useSearch(): [SearchState | null, UpdateSearchState] {
const ctx = React.useContext(SearchContext);
if (!ctx) {
throw new Error('useSearch must be used within SearchContextProvider');
export function useSetSearchState() {
const setState = React.useContext(SetSearchStateContext);
if (setState === undefined) {
throw new Error('useSetSearchState must be used within SearchContextProvider');
}
return [ctx.state, ctx.setState];
return setState;
}
export function useSearchState() {
const state = React.useContext(SearchStateContext);
if (state === undefined) {
throw new Error('useSearchState must be used within SearchContextProvider');
}
return state;
}
/**
@@ -137,7 +145,7 @@ export function useSearchLink(): (
params: Partial<SearchState>,
callback?: () => void
) => LinkProps {
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return React.useCallback(
(params, callback) => {
@@ -71,7 +71,13 @@ export async function SitePage(props: SitePageProps & { staticRoute: boolean })
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
{/* Using `contents` makes the children of this div according to its parent which keeps them in a single flex row with the TOC by default.
If there's a page cover, we use `flex flex-col` to lay out the PageCover above the PageBody + PageAside instead. */}
<div className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}>
<div
className={
withFullPageCover && page.cover
? 'flex grow flex-col justify-start'
: 'contents'
}
>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
@@ -8,6 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useSpaceBasePath } from '../SpaceLayout/SpaceLayoutContext';
import { CurrentPageProvider } from '../hooks';
import { SuspenseLoadedHint } from '../primitives';
/**
* Component that displays a "page not found" message.
@@ -20,11 +21,18 @@ export function SitePageNotFound() {
// If ?fallback=true, redirect to the root page.
const fallback = searchParams?.get('fallback');
const ask = searchParams?.get('ask');
useEffect(() => {
if (fallback) {
router.replace(basePath);
} else if (ask) {
// This is a workaround to prevent an infinite loop when the ask parameter is present.
// If it is present while we are on a 404 page, Next.js will fetch the RSC of the page again,
// trigerring the page to rerender and the assistant to start again, causing the infinite loop.
// To prevent this, when we detect the ask parameter, we redirect to the base path where the error does not occur.
router.replace(`${basePath}?${searchParams?.toString()}`);
}
}, [basePath, fallback, router]);
}, [basePath, fallback, ask, router, searchParams]);
return (
<CurrentPageProvider page={null}>
@@ -45,6 +53,7 @@ export function SitePageNotFound() {
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
<SuspenseLoadedHint />
{/* Track the page not found as a page view */}
<TrackPageViewEvent displayContext={SiteInsightsDisplayContext.Site} />
@@ -105,11 +105,13 @@ export function SpaceLayout(props: SpaceLayoutProps) {
const withSections = Boolean(visibleSections && visibleSections.list.length > 1);
const variants = categorizeVariants(context);
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
const withFooter =
customization.themes.toggeable ||
customization.footer.copyright ||
customization.footer.logo ||
socialLinks.length > 0 ||
customization.footer.groups?.length;
return (
@@ -127,7 +129,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
'lg:flex-row',
'lg:justify-center',
CONTAINER_STYLE,
'site-width-wide:max-w-screen-4xl',
'layout-full-width:max-w-screen-4xl',
'transition-[max-width] duration-300',
// Ensure the footer is display below the viewport even if the content is not enough
@@ -51,6 +51,10 @@ export async function TableOfContents(props: {
'max-lg:not-sidebar-filled:border-r',
'border-tint-subtle',
// Hide completely in full-width mode
'layout-full-width:hidden!',
'lg:layout-full-width:hidden!',
'lg:flex!',
'lg:animate-none!',
'lg:sticky',
@@ -144,22 +144,21 @@ export const Button = React.forwardRef<
};
let iconElement = null;
if (icon) {
if (React.isValidElement(icon)) {
type IconElement = React.ReactElement<React.SVGProps<SVGSVGElement>>;
iconElement = React.cloneElement(icon as IconElement, {
className: tcls(
'button-leading-icon shrink-0',
iconSizeClasses[size],
(icon as IconElement).props.className
),
});
} else {
if (typeof icon === 'string') {
iconElement = (
<Icon
icon={icon as IconName}
className={tcls('button-leading-icon shrink-0', iconSizeClasses[size])}
/>
);
} else if (React.isValidElement<React.SVGProps<SVGSVGElement>>(icon)) {
iconElement = React.cloneElement(icon, {
className: tcls(
'button-leading-icon shrink-0',
iconSizeClasses[size],
icon.props.className
),
});
}
}
@@ -32,15 +32,37 @@ type CustomInputProps = {
* A keyboard shortcut, shown to the right of the input.
*/
keyboardShortcut?: boolean | KeyboardShortcutProps;
onSubmit?: (value: string | number | readonly string[] | undefined) => void;
/**
* Callback invoked when the user submits the input by pressing Enter.
* Only called if the input has a non-empty value.
*/
onSubmit?: (value: string) => void;
/**
* Controlled value of the input. When provided, the input becomes a controlled component.
*/
value?: string;
/**
* Callback invoked whenever the input value changes.
* Used to update the parent component's state in controlled mode.
*/
onValueChange?: (value: string) => void;
/**
* When true, automatically resizes the textarea vertically to fit its content.
* Only applies when multiline is true.
*/
resize?: boolean;
};
export type InputProps = CustomInputProps &
(
| ({ multiline?: false } & React.InputHTMLAttributes<HTMLInputElement>)
| ({ multiline: true } & React.TextareaHTMLAttributes<HTMLTextAreaElement>)
| ({ multiline?: false } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange'
>)
| ({ multiline: true } & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'value' | 'onChange'
>)
);
type InputElement = HTMLInputElement | HTMLTextAreaElement;
@@ -72,7 +94,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
'aria-busy': ariaBusy,
placeholder,
disabled,
onChange,
onValueChange,
onKeyDown,
maxLength,
minLength,
@@ -80,7 +102,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
...htmlProps
} = props;
const [value, setValue] = useControlledState(passedValue, passedValue ?? '');
const [value, setValue] = useControlledState(passedValue, passedValue ?? '', onValueChange);
const [submitted, setSubmitted] = React.useState(false);
const [height, setHeight] = React.useState<number>();
const inputRef = React.useRef<InputElement>(null);
@@ -120,7 +142,6 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
const handleChange = (event: React.ChangeEvent<InputElement>) => {
const newValue = event.target.value;
setValue(newValue);
onChange?.(event as React.ChangeEvent<HTMLInputElement & HTMLTextAreaElement>);
// Reset submitted state when user edits the value to allow re-submission
if (submitted) {
@@ -2,38 +2,19 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as React from 'react';
import type * as React from 'react';
export type KeyboardShortcutProps = {
keys: string[];
} & React.HTMLAttributes<HTMLDivElement>;
function getOperatingSystem() {
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'mac';
if (platform.includes('win')) return 'win';
return 'win';
}
export function KeyboardShortcut(props: KeyboardShortcutProps) {
const { keys, className } = props;
const [operatingSystem, setOperatingSystem] = React.useState<string | null>(null);
React.useEffect(() => {
setOperatingSystem(getOperatingSystem());
}, []);
return (
<div
aria-busy={operatingSystem === null ? 'true' : undefined}
className={tcls(
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex',
operatingSystem
? 'motion-safe:animate-fade-in motion-reduce:opacity-11'
: 'opacity-0'
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex'
)}
>
{keys.map((key, index) => {
@@ -41,7 +22,12 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
switch (key) {
case 'mod':
element = operatingSystem === 'mac' ? '⌘' : 'Ctrl';
element = (
<>
<span className="hidden [html.os-mac_&]:inline"></span>
<span className="inline [html.os-mac_&]:hidden">Ctrl</span>
</>
);
break;
case 'enter':
@@ -53,11 +39,7 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
key={index}
className={tcls(
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
key === 'mod'
? operatingSystem === 'mac'
? 'text-sm'
: 'text-xs'
: 'uppercase',
key === 'mod' ? 'text-xs [html.os-mac_&]:text-sm' : 'uppercase',
className
)}
>
@@ -18,13 +18,17 @@ export const NavigationLoader = () => {
void pathname;
const root = document.documentElement;
root.classList.add('route-change');
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
root.classList.remove('route-change');
});
return () => cancelAnimationFrame(raf2);
});
return () => cancelAnimationFrame(raf1);
return () => {
root.classList.remove('route-change');
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [pathname]);
return (
@@ -33,7 +33,7 @@ export type ImageResponsiveSize = {
width: number;
};
const MAX_DPR = 4;
const MAX_DPR = 3;
interface ImageCommonProps {
/**
+64 -6
View File
@@ -10,6 +10,7 @@ import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-t
import { parse as parseCacheControl } from '@tusbar/cache-control';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { cache } from '../cache';
import { isRollout } from '../rollout';
import { DataFetcherError, wrapCacheDataFetcherError } from './errors';
import type { GitBookDataFetcher } from './types';
@@ -51,11 +52,19 @@ export function createDataFetcher(
// API that are tied to the token
//
getPublishedContentSite(params) {
return getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
});
return getPublishedContentSite(
input,
{
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
},
// This api version is used to invalidate cache when we do breaking changes to the API client.
// Something not backward compatible like changing the shape of the data returned by the API.
// We should bump this version when we do breaking changes to this specific endpoint returned data.
// DO NOT BUMP IT for other non-breaking changes.
'0.158'
);
},
getSiteRedirectBySource(params) {
return getSiteRedirectBySource(input, {
@@ -86,6 +95,16 @@ export function createDataFetcher(
});
},
getRevisionPageDocument(params) {
if (
['lit7E3qYkBZs0ttOdClR'].includes(params.spaceId) ||
isRollout({ discriminator: params.spaceId, percentageRollout: 80 })
) {
return getRevisionPageDocumentV2(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
});
}
return getRevisionPageDocument(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
@@ -369,6 +388,44 @@ const getRevisionPageDocument = cache(
}
);
/**
* Get the document for a page.
* Compared to the v1 of `getRevisionPageDocument`, it dereferences the reusable content blocks.
*/
const getRevisionPageDocumentV2 = cache(
async (
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string }
) => {
'use cache';
return wrapCacheDataFetcherError(async () => {
return trace(
`getRevisionPageDocument(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
async () => {
const api = apiClient(input);
const res = await api.spaces.getPageDocumentInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
evaluated: 'deterministic-only',
dereferenced: 'reusable-contents',
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLifeFromResponse(res, 'max');
return res.data;
}
);
});
}
);
const getRevisionReusableContentDocument = cache(
async (
input: DataFetcherInput,
@@ -535,7 +592,8 @@ const getLatestOpenAPISpecVersionContent = cache(
const getPublishedContentSite = cache(
async (
input: DataFetcherInput,
params: { organizationId: string; siteId: string; siteShareKey: string | undefined }
params: { organizationId: string; siteId: string; siteShareKey: string | undefined },
_apiVersion: string
) => {
'use cache';
cacheTag(
+14
View File
@@ -32,6 +32,20 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
});
}
/**
* Check if the document contains an OpenAPI block at the top level.
*/
export function hasOpenAPIBlock(document: JSONDocument): boolean {
return hasTopLevelBlock(document, (block) => {
return (
block.type === 'swagger' ||
block.type === 'openapi-operation' ||
block.type === 'openapi-schemas' ||
block.type === 'openapi-webhook'
);
});
}
/**
* Check if a top level block matches a predicate.
*/
@@ -41,19 +41,21 @@ export async function getImageSize(
}
}
export type CloudflareResizeImageOptions = CloudflareImageOptions & {
signal?: AbortSignal;
/**
* Bypass the check to see if the image can be resized.
* This is useful for some format that are not supported by @next/og and need to be transformed
*/
bypassSkipCheck?: boolean;
};
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
/**
* Bypass the check to see if the image can be resized.
* This is useful for some format that are not supported by @next/og and need to be transformed
*/
bypassSkipCheck?: boolean;
}
options: CloudflareResizeImageOptions
): Promise<Response> {
const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip && !options.bypassSkipCheck) {
+80 -19
View File
@@ -13,6 +13,7 @@ import {
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
import type {
ContentRef,
JSONDocument,
RevisionFile,
RevisionPageDocument,
RevisionReusableContent,
@@ -77,6 +78,42 @@ export interface ResolveContentRefOptions {
iconStyle?: ClassValue;
}
/**
* Resolve a content reference from within a document.
* It wraps the normal resolution of content refs, to handle parts of the document
* that have been composed of reusable blocks from spaces outside the current site,
* and leverage the meta token to resolve them.
*/
export async function resolveContentRefInDocument(
document: JSONDocument,
contentRef: ContentRef,
context: GitBookAnyContext,
options: ResolveContentRefOptions = {}
): Promise<ResolvedContentRef | null> {
if (isContentRefInDifferentSpace(contentRef, context)) {
const withinSite = !!getBestTargetSpaceFromSite(context, contentRef.space);
if (!withinSite) {
// When the content ref points to some content outside the current site,
// we use the potentially provided token to resolve the content ref in the target space.
// This is the case when the document is composed of reusable blocks from spaces outside the current site.
const token = document.meta?.token;
if (token) {
const dataFetch = context.dataFetcher.withToken({ apiToken: token });
return resolveContentRef(
contentRef,
{
...context,
dataFetcher: dataFetch,
},
options
);
}
}
}
return resolveContentRef(contentRef, context, options);
}
/**
* Resolve a content reference to be rendered.
*/
@@ -115,7 +152,7 @@ export async function resolveContentRef(
case 'anchor':
case 'page': {
if (contentRef.space && contentRef.space !== space.id) {
if (isContentRefInDifferentSpace(contentRef, context)) {
return resolveContentRefInSpace(contentRef.space, context, contentRef, options);
}
@@ -196,13 +233,12 @@ export async function resolveContentRef(
}
case 'space': {
const targetSpace =
contentRef.space === context.space.id
? {
space: context.space,
siteSpace: 'siteSpace' in context ? context.siteSpace : null,
}
: await getBestTargetSpace(context, contentRef.space);
const targetSpace = !isContentRefInDifferentSpace(contentRef, context)
? {
space: context.space,
siteSpace: 'siteSpace' in context ? context.siteSpace : null,
}
: await getBestTargetSpace(context, contentRef.space);
if (!targetSpace) {
return null;
@@ -238,11 +274,12 @@ export async function resolveContentRef(
};
}
// TODO-DEREF: Remove this once we have rolled out the new reusable content deref in the API.
case 'reusable-content': {
// Figure out which space and revision the reusable content is in.
const container = await (async () => {
// without a space on the content ref, or if the space is the same as the current one, we can use the current revision.
if (!contentRef.space || contentRef.space === context.space.id) {
if (!isContentRefInDifferentSpace(contentRef, context)) {
return context;
}
@@ -306,6 +343,16 @@ export async function resolveContentRef(
}
}
/**
* Return true if a content ref points to another space than the current one.
*/
export function isContentRefInDifferentSpace<Ref extends ContentRef>(
contentRef: Ref,
context: GitBookAnyContext
): contentRef is Ref & { space: string } {
return 'space' in contentRef && !!contentRef.space && contentRef.space !== context.space.id;
}
/**
* Fallback to resolve a content ref.
* Called if we can't resolve the content ref to have a potential fallback to display to the
@@ -330,20 +377,14 @@ async function getBestTargetSpace(
context: GitBookAnyContext,
spaceId: string
): Promise<{ space: Space; siteSpace: SiteSpace | null } | undefined> {
const { dataFetcher } = context;
// In the context of sites, we try to find our target space in the site structure.
// because the url of this space will be in the same site.
if ('site' in context) {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceId
);
if (found) {
return { space: found.siteSpace.space, siteSpace: found.siteSpace };
}
const inSite = getBestTargetSpaceFromSite(context, spaceId);
if (inSite) {
return inSite;
}
const { dataFetcher } = context;
const fetchedSpace = await getDataOrNull(
dataFetcher.getSpace({
spaceId,
@@ -356,6 +397,26 @@ async function getBestTargetSpace(
return fetchedSpace ? { space: fetchedSpace, siteSpace: null } : undefined;
}
/**
* Find the best target space for a content ref, from the current site.
*/
function getBestTargetSpaceFromSite(
context: GitBookAnyContext,
spaceId: string
): { space: Space; siteSpace: SiteSpace | null } | undefined {
if ('site' in context) {
const found = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceId
);
if (found) {
return { space: found.siteSpace.space, siteSpace: found.siteSpace };
}
}
return undefined;
}
async function resolveContentRefInSpace(
spaceId: string,
context: GitBookAnyContext,
+23
View File
@@ -0,0 +1,23 @@
/**
* We often need to progressive rollout new data fetching methods to avoid hitting our API too hard.
*/
export function isRollout({
discriminator,
percentageRollout,
}: {
discriminator: string;
percentageRollout: number;
}): boolean {
if (process.env.NODE_ENV === 'development') {
return true;
}
// compute a simple hash of the discriminator
let hash = 0;
for (let i = 0; i < discriminator.length; i++) {
hash = (hash << 5) - hash + discriminator.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash % 100) < percentageRollout;
}
+10
View File
@@ -19,6 +19,16 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
icons: api.CustomizationIconsStyle.Regular,
links: api.CustomizationLinksStyle.Default,
depth: api.CustomizationDepth.Subtle,
codeTheme: {
default: {
light: api.CustomizationCodeTheme.DefaultLight,
dark: api.CustomizationCodeTheme.DefaultDark,
},
openapi: {
light: api.CustomizationCodeTheme.DefaultLight,
dark: api.CustomizationCodeTheme.DefaultDark,
},
},
sidebar: {
background: api.CustomizationSidebarBackgroundStyle.Default,
list: api.CustomizationSidebarListStyle.Default,
+77 -18
View File
@@ -9,8 +9,22 @@ import {
resizeImage,
verifyImageSignature,
} from '@/lib/images';
import type { CloudflareResizeImageOptions } from '@/lib/images/resizer';
import { NextResponse } from 'next/server';
const FORMATS = [
{
format: 'avif' as const,
regexp: /image\/avif/,
maxAllowedEdge: 1600,
},
{
format: 'webp' as const,
regexp: /image\/webp/,
maxAllowedEdge: 1920,
},
];
/**
* Resize an image using the Cloudflare Image API.
*/
@@ -70,10 +84,13 @@ export async function serveResizedImage(
return NextResponse.redirect(url, 302);
}
const defaultFormat = getOriginalFormatFromURL(url);
// Cloudflare-specific options are in the cf object.
const options: CloudflareImageOptions = {
fit: 'scale-down',
format: 'jpeg',
// For GIF, we will use webp as default format for resizing.
format: defaultFormat === 'gif' ? 'webp' : defaultFormat,
quality: 100,
};
@@ -101,24 +118,54 @@ export async function serveResizedImage(
// Check the Accept header to handle content negotiation
const accept = request.headers.get('accept');
// We use transform image, max size for avif should be 1600
// https://developers.cloudflare.com/images/transform-images/#limits-per-format
if (accept && /image\/avif/.test(accept) && longestEdgeValue <= 1600) {
options.format = 'avif';
options.dpr = chooseDPR(longestEdgeValue, 1600, options.dpr);
} else if (accept && /image\/webp/.test(accept) && longestEdgeValue <= 1920) {
options.format = 'webp';
options.dpr = chooseDPR(longestEdgeValue, 1920, options.dpr);
// We test if we can use AVIF based on the accept header and constraints from Cloudflare
// @see https://developers.cloudflare.com/images/transform-images/#limits-per-format
if (accept) {
for (const entry of FORMATS) {
if (entry.regexp.test(accept) && longestEdgeValue <= entry.maxAllowedEdge) {
const wantedDpr = options.dpr ?? 1;
const dpr = chooseDPR(longestEdgeValue, entry.maxAllowedEdge, wantedDpr);
if (dpr === wantedDpr) {
options.format = entry.format;
break;
}
}
}
}
return resizeImageWithFallback(
url,
options,
// For GIF, we won't fallback to any format, we will just serve the original
defaultFormat === 'gif' ? null : defaultFormat
);
}
/**
* Try to resize the image in an optimized format.
* If not possible, fallback to a default format.
*/
async function resizeImageWithFallback(
url: string,
options: CloudflareResizeImageOptions,
formatFallback: 'jpeg' | 'png' | null
) {
try {
const response = await resizeImage(url, options);
if (!response.ok) {
throw new Error(`Failed to resize image, received status code ${response.status}`);
}
return response;
} catch (error) {
if (formatFallback && options.format !== formatFallback) {
return resizeImageWithFallback(
url,
{ ...options, format: formatFallback },
formatFallback
);
}
// Redirect to the original image if resizing fails
console.warn('Error while resizing image, redirecting to original', error);
return NextResponse.redirect(url, 302);
@@ -126,16 +173,28 @@ export async function serveResizedImage(
}
/**
* Choose the appropriate device pixel ratio (DPR) based on the longest edge of the image.
* This function ensures that the DPR is within a reasonable range (1 to 3).
* This is only used for AVIF/WebP formats to avoid issues with Cloudflare resizing.
* It means that dpr may not be respected for avif/webp formats, but it will also improve the cache hit ratio.
* Get the original format from URL.
*/
function chooseDPR(longestEdgeValue: number, maxAllowedSize: number, wantedDpr?: number): number {
const maxDprBySize = Math.floor(maxAllowedSize / longestEdgeValue);
const clampedDpr = Math.min(wantedDpr ?? 1, 3); // Limit to a maximum of 3, default to 1 if not specified
function getOriginalFormatFromURL(url: string) {
const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase();
if (pathname.endsWith('.gif')) {
return 'gif';
}
if (pathname.endsWith('.png')) {
return 'png';
}
return 'jpeg';
}
/**
* Choose the DPR allowed to resize an image on Cloudflare.
* @see https://developers.cloudflare.com/images/transform-images/#limits-per-format
*/
function chooseDPR(longestEdgeValue: number, maxAllowedEdge: number, wantedDpr: number): number {
const maxDprBySize = Math.floor(maxAllowedEdge / longestEdgeValue);
// Ensure that the DPR is within the allowed range
return Math.max(1, Math.min(maxDprBySize, clampedDpr));
return Math.max(1, Math.min(maxDprBySize, wantedDpr));
}
/**
+10
View File
@@ -677,6 +677,16 @@ const config: Config = {
*/
addVariant('page-api-block', 'body:has(.openapi-block) &');
/**
* Layout mode variants for controlling the page structure.
* - layout-default: 3-column layout (TOC + Content + Outline)
* - layout-openapi: 2-column layout (TOC + Content, no Outline)
* - layout-full-width: 1-column layout (Content only, no TOC or Outline)
*/
addVariant('layout-default', 'body:has(.layout-default) &');
addVariant('layout-openapi', 'body:has(.layout-openapi) &');
addVariant('layout-full-width', 'body:has(.layout-full-width) &');
/**
* Variant when the page is displayed in print mode.
*/
+5 -12
View File
@@ -5,17 +5,10 @@ import url from 'node:url';
* Get the path to the kit, depending on the Pro or Free version.
*/
export function getKitPath() {
let source = path.dirname(
url.fileURLToPath(import.meta.resolve('@fortawesome/fontawesome-free/package.json'))
return path.resolve(
path.dirname(
url.fileURLToPath(import.meta.resolve('@gitbook/fontawesome-pro/package.json'))
),
'icons'
);
try {
source = path.resolve(
path.dirname(
url.fileURLToPath(import.meta.resolve('@gitbook/fontawesome-pro/package.json'))
),
'icons'
);
} catch (_error) {}
return source;
}
-3
View File
@@ -17,9 +17,6 @@
"version": "0.4.2",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/fontawesome-free": "^7.1.0"
},
"optionalDependencies": {
"@gitbook/fontawesome-pro": "1.0.16"
},
"devDependencies": {
+2 -2
View File
@@ -11,8 +11,8 @@
"version": "3.0.7",
"sideEffects": false,
"dependencies": {
"@scalar/json-magic": "^0.8.10",
"@scalar/openapi-parser": "^0.23.12",
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/openapi-types": "^0.5.3"
},
"devDependencies": {
@@ -11,7 +11,7 @@ import { generateMediaTypeExamples, generateSchemaExample } from './generateSche
import { stringifyOpenAPI } from './stringifyOpenAPI';
import type { OpenAPIOperationData } from './types';
import { mergeHeaders } from './util/headers';
import { getDefaultServerURL } from './util/server';
import { getDefaultServerURL, hasValidServerHost } from './util/server';
import {
resolvePrefillCodePlaceholderFromSecurityScheme,
resolveURLWithPrefillCodePlaceholdersFromServer,
@@ -216,11 +216,14 @@ function OpenAPICodeSampleFooter(props: {
const hasMultipleMediaTypes =
renderers.length > 1 || renderers.some((renderer) => renderer.examples.length > 0);
// Check if any server has a host that can be used in an HTTP request
const hasValidHost = hasValidServerHost(servers);
if (hideTryItPanel && !hasMultipleMediaTypes) {
return null;
}
if (!validateHttpMethod(method) || (!hasMultipleMediaTypes && servers.length === 0)) {
if (!validateHttpMethod(method) || (!hasMultipleMediaTypes && !hasValidHost)) {
return null;
}
@@ -237,7 +240,7 @@ function OpenAPICodeSampleFooter(props: {
) : (
<span />
)}
{!hideTryItPanel && servers.length > 0 && (
{!hideTryItPanel && hasValidHost && (
<ScalarApiButton
context={getOpenAPIClientContext(context)}
method={method}
@@ -18,6 +18,14 @@ export function OpenAPIMediaTypeExamplesSelector(props: {
const state = useSelectState(stateKey, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === state.key) || renderers[0];
const hasMultipleMediaTypes = renderers.length >= 2;
const hasMultipleExamples = selected.examples.length >= 2;
// Only render the wrapper div if at least one selector will render
if (!hasMultipleMediaTypes && !hasMultipleExamples) {
return null;
}
return (
<div className="openapi-codesample-selectors">
<MediaTypeSelector selectIcon={selectIcon} stateKey={stateKey} renderers={renderers} />
@@ -31,9 +31,15 @@ export function OpenAPIMediaTypeContent(props: {
const { stateKey, items, selectIcon, context } = props;
const state = useMediaTypesState(stateKey, items[0]?.key);
const examples = items.find((item) => item.key === state.key)?.examples ?? [];
const item = items.find((item) => item.key === state.key) ?? items[0];
if (!items.length && !examples.length) {
if (!item) {
return null;
}
const examples = item.examples;
if (!examples || !examples.length) {
return null;
}
+9 -2
View File
@@ -16,7 +16,13 @@ import { retrocycle } from './decycle';
import { getDisclosureLabel } from './getDisclosureLabel';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { tString } from './translate';
import { checkIsReference, getSchemaTitle, resolveDescription, resolveFirstExample } from './utils';
import {
checkIsReference,
getEffectiveArrayType,
getSchemaTitle,
resolveDescription,
resolveFirstExample,
} from './utils';
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
@@ -641,7 +647,8 @@ function getSchemaProperties(
discriminatorValue?: string | undefined
): null | OpenAPISchemaPropertyEntry[] {
// check array AND schema.items as this is sometimes null despite what the type indicates
if (schema.type === 'array' && schema.items && !checkIsReference(schema.items)) {
const arrayInfo = getEffectiveArrayType(schema);
if (arrayInfo.isArray && schema.items && !checkIsReference(schema.items)) {
const items = schema.items;
const itemProperties = getSchemaProperties(items, discriminator, discriminatorValue);
if (itemProperties) {
@@ -2,6 +2,7 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import type React from 'react';
import type { OpenAPIClientContext } from './context';
import { t, tString } from './translate';
import { getEffectiveArrayType } from './utils';
interface OpenAPISchemaNameProps {
schema?: OpenAPIV3.SchemaObject;
@@ -82,8 +83,11 @@ function getAdditionalItems(schema: OpenAPIV3.SchemaObject, context: OpenAPIClie
additionalItems += ` · ${tString(context.translation, 'max').toLowerCase()}: ${schema.maximum || schema.maxLength || schema.maxItems}`;
}
if (schema.nullable) {
additionalItems = ` | ${tString(context.translation, 'nullable').toLowerCase()}`;
// Check for nullable in both OpenAPI 3.0 (nullable: true) and OpenAPI 3.1 (type: ['null', ...])
const schemaArrayInfo = getEffectiveArrayType(schema);
const isNullable = schema.nullable || schemaArrayInfo.hasNull;
if (isNullable) {
additionalItems += ` · ${tString(context.translation, 'nullable').toLowerCase()}`;
}
return additionalItems;
+7 -1
View File
@@ -2,7 +2,13 @@ import clsx from 'classnames';
import { type ComponentPropsWithoutRef, forwardRef } from 'react';
export function Section(props: ComponentPropsWithoutRef<'div'>) {
return <div {...props} className={clsx('openapi-section', props.className)} />;
return (
<div
{...props}
data-follow-color-scheme="true"
className={clsx('openapi-section', props.className)}
/>
);
}
export function SectionHeader(props: ComponentPropsWithoutRef<'div'>) {

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