Compare commits

..

2 Commits

Author SHA1 Message Date
Viktor Renkema c5d1bbfafe wip 2026-02-12 09:40:27 +01:00
Viktor Renkema dac5e9d444 Fix logo sizing bug during navigation 2026-01-21 20:25:17 +01:00
104 changed files with 1581 additions and 2817 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"gitbook": minor
"@gitbook/react-openapi": patch
---
Support code themes customization
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Support social links
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Fix OpenAPI example not showing
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/openapi-parser": patch
---
Update scalar dependencies.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Improve OpenAPI server URL validation
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix ordered list item index calculation
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/icons": patch
---
Simplify installation by using public fontawesome mirror.
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Add OpenAPI 3.1 nullable array support
+7 -2
View File
@@ -5,6 +5,8 @@ on:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -34,10 +36,13 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build:
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build
name: Build (Open Source)
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+2
View File
@@ -4,6 +4,8 @@ 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,6 +3,8 @@ 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,6 +3,8 @@ 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,6 +4,9 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
+17 -1
View File
@@ -77,7 +77,23 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
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.
## Contributing
+37 -30
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.6",
"turbo": "^2.7.4",
"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.11",
"next": "15.4.10",
"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.21.0",
"shiki": "^3.2.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,6 +235,9 @@
"tsdown": "catalog:",
"typescript": "catalog:",
},
"optionalDependencies": {
"@gitbook/fontawesome-pro": "1.0.16",
},
"peerDependencies": {
"react": "*",
},
@@ -243,8 +246,8 @@
"name": "@gitbook/openapi-parser",
"version": "3.0.7",
"dependencies": {
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/json-magic": "^0.8.10",
"@scalar/openapi-parser": "^0.23.12",
"@scalar/openapi-types": "^0.5.3",
},
"devDependencies": {
@@ -345,7 +348,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.159.0",
"@gitbook/api": "0.158.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -722,9 +725,11 @@
"@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.159.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-IRZXT2NAcOlKlCcGnzRH4w82AuBXam5I7lw84qfCRbNDDycvFNBwIVMHu3JMOcGUTCkzj7QDElaNKYirsWDwFQ=="],
"@gitbook/api": ["@gitbook/api@0.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -870,7 +875,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.11", "", {}, "sha512-mIYp/091eYfPFezKX7ZPTWqrmSXq+ih6+LcUyKvLmeLQGhlPtot33kuEOd4U+xAA7sFfj21+OtCpIZx0g5SpvQ=="],
"@next/env": ["@next/env@15.4.10", "", {}, "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A=="],
@@ -1284,23 +1289,23 @@
"@scalar/draggable": ["@scalar/draggable@0.2.0", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-UetHRB5Bqo5egVYlS21roWBcICmyk8CKh2htsidO+bFGAjl2e7Te+rY0borhNrMclr0xezHlPuLpEs1dvgLS2g=="],
"@scalar/helpers": ["@scalar/helpers@0.2.9", "", {}, "sha512-Y4ffJF0yELdwZ0BKgonqn3SumIgRn1WKyYCVHD+TDM7qRFChdGRypyt20+efHs26fmJeyBAIIv2laICj5uimiw=="],
"@scalar/helpers": ["@scalar/helpers@0.2.6", "", {}, "sha512-A471YFBCj7ZOlGIkAYnU8oYgeyts82ZNX+4UicrlmKv3eAQ+kwboN3Dy0R6u1lcA/+I/zzeXi/fBObsT7P9qTA=="],
"@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.9.4", "", { "dependencies": { "@scalar/helpers": "0.2.9", "yaml": "^2.8.0" } }, "sha512-PyfyWrH4ZkW0TM1ColiiHj4NRF8hUM61H0UzAkHLhRNnKFxi6hI+oqNrwqPnyk93hrpkpTRHC7Fl5T0BRwuzVg=="],
"@scalar/json-magic": ["@scalar/json-magic@0.8.10", "", { "dependencies": { "@scalar/helpers": "0.2.6", "yaml": "^2.8.0" } }, "sha512-TWdKQ/hcy4erFQDp2MVlFoPesFep2VY96Q69cjLHmx5hxM0ZUBfmNB4lA8Uh3klgx5JmCDfSNIGjPFIpxlosUw=="],
"@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.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-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-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.8", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-2xuYLLs0fBadLIk4I1ObjMiCnOyLPEMPf24A1HtHQvhKGDnGlvT63F2rU2Xw8lxCjgHnzveMPnOJEbwIy64RCg=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.6", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-XdrNZUr0ASLfR89OS2zP6enbq9f7UGQQxov+a3WF1Wz9DClniAL2ChJ2fbGOrqL5F2kjbV6Fw/iO3bsBTMyLZA=="],
"@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=="],
@@ -1320,17 +1325,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.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/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/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-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-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="],
"@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/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="],
"@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="],
"@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="],
"@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="],
"@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="],
"@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/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -1898,6 +1903,8 @@
"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=="],
@@ -2508,7 +2515,7 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"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": ["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-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=="],
@@ -2550,9 +2557,9 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-parser": ["oniguruma-parser@0.5.4", "", {}, "sha512-yNxcQ8sKvURiTwP0mV6bLQCYE7NKfKRRWunhbZnXgxSmB1OXa1lHrN3o4DZd+0Si0kU5blidK7BcROO8qv5TZA=="],
"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=="],
"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=="],
"openapi-fetch": ["openapi-fetch@0.13.5", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-AQK8T9GSKFREFlN1DBXTYsLjs7YV2tZcJ7zUWxbjMoQmj8dDSFRrzhLCbHPZWA1TMV3vACqfCxLEZcwf2wxV6Q=="],
@@ -2772,7 +2779,7 @@
"shell-quote": ["shell-quote@1.8.1", "", {}, "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA=="],
"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=="],
"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=="],
"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=="],
@@ -2906,19 +2913,19 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"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": ["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-darwin-64": ["turbo-darwin-64@2.7.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-bYu0qnWju2Ha3EbIkPCk1SMLT3sltKh1P/Jy5FER6BmH++H5z+T5MHh3W1Xoers9rk4N1VdKvog9FO1pxQyjhw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDR30ltfkSsRfGzABBckvl1nz1cZ3ssTujvdj+TPwOweeDRvZ0e06t5DS0rmRBvyKpgGs42K/EK6Mn2qLlFY9A=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KCxTf3Y1hgNLYIWRLw8bwH8Zie9RyCGoxAlXYsCBI/YNqBSR+ZZK9KYzFxAqDaVaNvTwLFv3rJRGsXOFWg4+Uw=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7sjqXtOL/+nYWPvcDGWhi8wf8M8mZHHB8XEzw2VX7VJrS8IGHyJHGD1AYfDvhAEcr7pnk3gGifz3/xyhI655w=="],
"turbo-linux-64": ["turbo-linux-64@2.7.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vjoU8zIfNgvJR3cMitgw7inEoi6bmuVuFawDl5yKtxjAEhDktFdRBpGS3WojD4l3BklBbIK689ssXcGf21LxRA=="],
"turbo-linux-64": ["turbo-linux-64@2.7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GofFOxRO/IhG8BcPyMSSB3Y2+oKQotsaYbHxL9yD6JPb20/o35eo+zUSyazOtilAwDHnak5dorAJFoFU8MIg2A=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TcMpBvTqZf+1DptrVYLbZls7WY1UVNDTGaf0bo7/GCgWYv5eZHCVo4Td7kCJeDU4glbXg67REX0md0S0V6ghMg=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+RQKgNjksVPxYAyAgmDV7w/1qj++qca+nSNTAOKGOfJiDtSvRKoci89oftJ6anGs00uamLKVEQ712TI/tfNAIw=="],
"turbo-windows-64": ["turbo-windows-64@2.7.6", "", { "os": "win32", "cpu": "x64" }, "sha512-1/MhkYldiihjneY8QnnDMbAkHXn/udTWSVYS94EMlkE9AShozsLTTOT1gDOpX06EfEW5njP09suhMvxbvwuwpQ=="],
"turbo-windows-64": ["turbo-windows-64@2.7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-rfak1+g+ON3czs1mDYsCS4X74ZmK6gOgRQTXjDICtzvR4o61paqtgAYtNPofcVsMWeF4wvCajSeoAkkeAnQ1kg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0wDVnUJLFAWm4ZzOQFDkbyyUqaszorTGf3Rdc22IRIyJTTLd6ajqdb+cWD89UZ1RKr953+PZR1gqgWQY4PDuhA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-1ZgBNjNRbDu/fPeqXuX9i26x3CJ/Y1gcwUpQ+Vp7kN9Un6RZ9kzs164f/knrjcu5E+szCRexVjRSJay1k5jApA=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
+2
View File
@@ -0,0 +1,2 @@
[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.6",
"turbo": "^2.7.4",
"vercel": "^39.4.2"
},
"packageManager": "bun@1.3.7",
"packageManager": "bun@1.3.2",
"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.159.0",
"@gitbook/api": "0.158.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
-41
View File
@@ -8,7 +8,6 @@ import {
CustomizationIconsStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { expect } from '@playwright/test';
@@ -1124,16 +1123,6 @@ 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/',
@@ -1150,36 +1139,6 @@ 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,7 +2,6 @@ import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCodeTheme,
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
@@ -299,16 +298,6 @@ 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.11",
"next": "15.4.10",
"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.21.0",
"shiki": "^3.2.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 { useSetSearchState } from '../Search/useSearch';
import { useSearch } 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 = useSetSearchState();
const [, setSearchState] = useSearch();
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 { useSetSearchState } from '../Search';
import { useSearch } 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 = useSetSearchState();
const [, setSearchState] = useSearch();
const language = useLanguage();
// Event listeners storage
@@ -4,7 +4,6 @@ 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';
@@ -22,6 +21,7 @@ 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} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
@@ -141,7 +141,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
<ToolbarSubtitle
subtitle={
<>
<DateRelative value={changeRequest.updatedAt} /> by {author}
<ToolbarDate value={changeRequest.updatedAt} /> by {author}
</>
}
/>
@@ -207,13 +207,13 @@ function RevisionToolbar(props: ToolbarViewProps) {
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Created <DateRelative value={revision.createdAt} />
Created <ToolbarDate value={revision.createdAt} />
</>
}
/>
@@ -280,20 +280,10 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
});
return (
<Toolbar
minified={minified}
onMinifiedChange={onMinifiedChange}
label="Only visible to your GitBook organization"
>
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle
subtitle={
<>
Updated <DateRelative value={revision.createdAt} />
</>
}
/>
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarSeparator />
<ToolbarActions>
@@ -305,13 +295,13 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
{/* Open site in GitBook */}
<ToolbarButton
title="Open site in GitBook"
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="gears"
icon="folder-gear"
/>
{/* Customize in GitBook */}
@@ -361,13 +351,13 @@ function EditPageButton(props: {
return (
<ToolbarButton
title="Edit in GitBook"
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pencil"
icon="pen-to-square"
motionValues={motionValues}
/>
);
@@ -8,10 +8,20 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
import styles from './Toolbar.module.css';
import { useToolbarControls } from './ToolbarControlsContext';
const ARC_DURATION_SECONDS = 0.4;
const ARC_STAGGER_MS = 80;
const BASE_ROTATION_DEG = 95;
const ROTATION_STEP_DEG = 18;
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;
interface HideToolbarButtonProps {
motionValues?: ToolbarButtonProps['motionValues'];
@@ -23,30 +33,75 @@ 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;
}
setOpen(false);
close();
};
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll
// 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
React.useEffect(() => {
if (!open) return;
const handleScroll = () => setOpen(false);
window.addEventListener('scroll', handleScroll, { passive: true });
const handleClose = () => close();
window.addEventListener('scroll', handleClose, { passive: true });
window.addEventListener('resize', handleClose, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [open]);
return () => {
window.removeEventListener('scroll', handleClose);
window.removeEventListener('resize', handleClose);
};
}, [open, close]);
const items = [
controls?.minimize
@@ -84,36 +139,80 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
return (
<ToolbarButton
ref={buttonRef}
title={open ? 'Hide options' : 'Hide toolbar'}
title={open ? undefined : 'Hide toolbar'}
className={
open || closing
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
: undefined
}
onClick={() => {
setOpen((v) => !v);
if (open || closing) {
close();
} else {
setOpen(true);
}
}}
motionValues={motionValues}
icon="eye-slash"
icon="gear"
>
{/* Expanding arc menu */}
{open && (
{(open || closing) && (
<motion.div
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
style={sharedMotionStyle as React.CSSProperties | undefined}
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
}
>
<div
className={tcls(
'pointer-events-none absolute left-0 overflow-visible',
'pointer-events-none absolute overflow-visible',
shouldMirror ? '' : 'left-0',
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={items.length - 1 - index}
key={item.icon}
staggerIndex={closing ? index : items.length - 1 - index}
key={item.id}
mirrored={shouldMirror}
flippedVertical={shouldFlipVertical}
closing={closing}
{...item}
onClick={() => {
setOpen(false);
item.onClick?.();
}}
onClick={
item.isLabel
? undefined
: () => {
close();
item.onClick?.();
}
}
/>
))}
</div>
@@ -127,13 +226,17 @@ type ArcMenuItem = {
id: string;
icon: IconName;
label: string;
description: string;
description?: string;
onClick?: () => void;
isLabel?: boolean;
};
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick' | 'isLabel'> & {
index: number;
staggerIndex?: number;
mirrored?: boolean;
flippedVertical?: boolean;
closing?: boolean;
disabled?: boolean;
className?: string;
iconClassName?: string;
@@ -143,40 +246,41 @@ 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)`;
// Calculate rotation based on position along the arc
const calculateRotation = () => {
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
};
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
const itemRotation = calculateRotation();
const Tag = isLabel ? 'div' : 'button';
return (
<div className="pointer-events-none">
<button
type="button"
onClick={() => {
onClick();
}}
<Tag
{...(Tag === 'button' ? { type: 'button' as const } : {})}
onClick={onClick ? () => onClick() : undefined}
style={
{
'--from-offset-distance': fromOffset,
'--target-offset-distance': targetOffset,
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
'--rotation-offset': `${itemRotation}deg`,
offsetPath: 'border-box',
offsetDistance: targetOffset,
offsetAnchor: '0% 40%',
offsetRotate: `auto ${itemRotation}deg`,
offsetDistance: fromOffset,
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
offsetRotate: 'auto 90deg',
} as React.CSSProperties
}
className={tcls(
@@ -185,49 +289,61 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
'top-0',
'left-0',
'w-40',
'opacity-0',
'pointer-events-auto',
'flex',
'items-center',
'gap-2',
styles.arcMenuItem,
mirrored ? 'flex-row-reverse' : '',
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
className
)}
>
<div
className={tcls(
'flex shrink-0 items-center justify-center gap-1',
'h-8 w-8 rounded-full border',
'truncate text-sm',
'cursor-pointer transition-colors',
'group-hover:-rotate-5 group-hover:scale-105',
disabled ? 'cursor-not-allowed opacity-50' : '',
'text-tint-1 dark:text-tint-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
)}
style={{
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
}}
className="flex items-center gap-2"
style={flippedVertical ? { transform: 'scaleY(-1)' } : undefined}
>
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Solid}
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
/>
{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>
</>
)}
</div>
<span
className={tcls(
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
'group-hover:rotate-2 group-hover:scale-105',
'text-neutral-1 dark:text-neutral-12',
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
)}
>
{label}
</span>
</button>
</Tag>
</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: forwards;
animation-fill-mode: both;
animation-duration: var(--arc-duration, 0.4s);
animation-delay: var(--arc-delay, 0s);
transform-origin: center left;
@@ -28,13 +28,101 @@
@keyframes hide-toolbar-arc-enter {
from {
offset-distance: var(--start-distance);
transform: scale(0.5);
opacity: 0;
offset-distance: var(--from-offset-distance);
offset-rotate: auto 90deg;
}
to {
offset-distance: var(--target-offset-distance);
transform: scale(1);
opacity: 1;
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;
}
}
@@ -2,39 +2,71 @@
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, label, minified, onMinifiedChange } = props;
const { children, 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 = () => {
@@ -80,58 +112,432 @@ 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 (
<Tooltip label={label}>
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
<AnimatePresence mode="wait">
<motion.div
onClick={() => {
if (minified) {
setShouldAnimateLogo(false);
onMinifiedChange(false);
}
}}
layout
transition={toolbarEasings.spring}
className={tcls(
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
'flex',
'items-center',
'justify-center',
'min-h-11',
'min-w-12',
'h-12',
'py-2',
'backdrop-blur-sm',
'origin-center',
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
)}
style={{
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
}}
>
{/* Logo with stroke segments animation in blue-tints */}
<motion.div layout>
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
<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>
)}
{!minified ? children : null}
</motion.div>
</AnimatePresence>
</motion.div>
</Tooltip>
<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
}
>
{/* 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}
</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>
);
}
@@ -197,63 +603,53 @@ 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}
<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>
{title && !isDragging ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
</motion.div>
);
});
@@ -339,7 +735,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
return (
<motion.span
{...getCopyVariants(1)}
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
>
{props.subtitle}
</motion.span>
@@ -0,0 +1,142 @@
'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,6 +11,8 @@ 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';
@@ -57,6 +59,35 @@ 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 { document, block, context, style } = props;
const { block, context, style } = props;
const resolved = context.contentContext
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext, {
? await resolveContentRef(block.data.ref, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['text-xl', 'text-tint'],
})
@@ -84,11 +84,6 @@ 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}`}
@@ -99,12 +94,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
? 'max-w-screen-xl'
: 'max-w-3xl',
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
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',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle,
]}
isEstimatedOffscreen={isOffscreen}
@@ -1,6 +1,6 @@
'use client';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive';
@@ -8,19 +8,18 @@ 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 { type ClassValue, tcls } from '@/lib/tailwind';
import { 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 { HighlightTheme, RenderedInline } from './highlight';
import type { HighlightLine, 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;
@@ -30,7 +29,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, themes } = props;
const { block, mode, style, inlines, inlineExprVariables } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
@@ -41,17 +40,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
visitorClaims,
variables: inlineExprVariables,
});
const plainTheme = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
[block, inlines, evaluateInlineExpression, themes]
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
);
const [theme, setTheme] = useState<null | HighlightTheme>(null);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted.
useEffect(() => {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block, themes));
}, [block, themes]);
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
// When user scrolls, we need to wait for the scroll to finish before running the highlight
const isScrollingRef = useRef(false);
@@ -100,16 +99,14 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines, { evaluateInlineExpression, themes }).then(
(theme) => {
if (cancelled) {
return;
}
setTheme(theme);
setHighlighting(false);
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
if (cancelled) {
return;
}
);
setLines(lines);
setHighlighting(false);
});
});
}
@@ -119,12 +116,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}
// Otherwise if the block is not in viewport, we reset to the plain lines
setTheme(null);
}, [isInViewport, block, inlines, evaluateInlineExpression, themes]);
setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]);
const expandable = block.data.expandable;
const numberOfLinesOfCode = theme?.lines.length ?? plainTheme.lines.length;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
@@ -139,17 +136,16 @@ export function ClientCodeBlock(props: ClientBlockProps) {
aria-busy={highlighting}
block={block}
style={style}
theme={theme ?? plainTheme}
lines={lines ?? plainLines}
id={codeBlockBodyId}
/>
);
return isExpandable ? (
<CodeBlockExpandable
theme={theme ?? plainTheme}
lines={lines ?? plainLines}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
style={style}
>
{renderer}
</CodeBlockExpandable>
@@ -160,25 +156,21 @@ export function ClientCodeBlock(props: ClientBlockProps) {
function CodeBlockExpandable(props: {
children: React.ReactNode;
theme: HighlightTheme;
lines: HighlightLine[];
collapsedLineCount: number;
controls?: string;
style?: ClassValue;
}) {
const { children, controls, theme, collapsedLineCount, style } = props;
const { children, controls, lines = [], collapsedLineCount } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div
className={tcls('group/codeblock-expandable relative', style)}
data-follow-color-scheme="true"
aria-expanded={isExpanded}
>
<div className="group/codeblock-expandable relative">
<div
className={tcls(
!isExpanded
? '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden'
: ''
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
)}
style={
{
@@ -189,7 +181,7 @@ function CodeBlockExpandable(props: {
>
{children}
</div>
<div className="pointer-events-none absolute bottom-2 flex w-full justify-center">
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<Button
icon={<ToggleChevron open={isExpanded} />}
size="xsmall"
@@ -202,7 +194,7 @@ function CodeBlockExpandable(props: {
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', theme.lines.length)}
: t(language, 'code_block_collapsed', lines.length)}
</Button>
</div>
</div>
@@ -1,10 +1,6 @@
import * as React from 'react';
import type {
CustomizationThemedCodeTheme,
DocumentBlockCode,
SiteCustomizationSettings,
} from '@gitbook/api';
import type { DocumentBlockCode } from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document';
@@ -17,21 +13,8 @@ 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> & {
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}
) {
const {
block,
document,
style,
isEstimatedOffscreen,
context,
themeKey = 'default',
themes: providedThemes,
} = props;
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = props;
const inlines = getInlines(block);
let hasInlineExpression = false;
@@ -67,19 +50,10 @@ export async function CodeBlock(
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 theme = await highlight(block, richInlines, {
themes: themes,
});
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
}
const variables = context.contentContext
@@ -100,7 +74,6 @@ export async function CodeBlock(
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, HighlightTheme, HighlightToken } from './highlight';
import type { HighlightLine, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
theme: HighlightTheme;
lines: HighlightLine[];
'aria-busy'?: boolean;
id?: string;
};
@@ -22,7 +22,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -30,35 +30,15 @@ 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 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}`}
className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? (
<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,
}}
>
<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">
{title}
</div>
) : null}
@@ -69,32 +49,23 @@ 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 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!'
'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'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
<code
id={codeId}
className={tcls(
'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%'
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
)}
>
{theme.lines.map((line, index) => (
{lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === theme.lines.length - 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
@@ -106,35 +77,13 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
function CodeHighlightLine(props: {
line: HighlightLine;
bg?: ShikiColorDefinition;
fg?: ShikiColorDefinition;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers, bg, fg } = props;
const { line, isLast, withLineNumbers } = props;
return (
<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={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers && <span className="highlight-line-number" />}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -163,53 +112,13 @@ function CodeHighlightToken(props: { token: HighlightToken }) {
return token.content;
}
case 'shiki': {
if (!token.token.htmlStyle) {
if (!token.token.color) {
return token.token.content;
}
return <span style={token.token.htmlStyle}>{token.token.content}</span>;
return <span style={{ color: token.token.color }}>{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,8 +1,4 @@
import type {
CustomizationThemedCodeTheme,
JSONDocument,
SiteCustomizationSettings,
} from '@gitbook/api';
import type { JSONDocument } from '@gitbook/api';
import { useId } from 'react';
import { CodeBlock } from './CodeBlock';
@@ -12,13 +8,8 @@ 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;
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, themeKey, themes } = props;
export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -35,8 +26,6 @@ export function PlainCodeBlock(props: {
context={{
mode: 'default',
}}
themeKey={themeKey}
themes={themes}
block={block}
ancestorBlocks={[]}
// We optimize perf by default
@@ -1,832 +0,0 @@
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,8 +8,7 @@ async function highlightWithInlines(block: DocumentBlockCode) {
inline,
body: null,
}));
const result = await highlight(block, inlines);
return result.lines;
return highlight(block, inlines);
}
it('should parse plain code', async () => {
@@ -225,19 +224,7 @@ it('should parse code with an inline on a single line', async () => {
{
type: 'shiki',
token: {
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
content: '"Hello World"',
},
},
{
@@ -388,19 +375,7 @@ it('should parse code with an inline on a multiple line', async () => {
{
type: 'shiki',
token: {
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
content: '"Hello World"',
},
},
{
@@ -656,13 +631,7 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'world',
content: '.world',
},
},
{
@@ -676,13 +645,7 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: ')',
},
},
{
type: 'shiki',
token: {
content: ';',
content: ');',
},
},
],
@@ -736,13 +699,7 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
content: '.log',
},
},
{
@@ -754,19 +711,7 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello',
},
},
{
type: 'shiki',
token: {
content: '"',
content: '"Hello"',
},
},
{
@@ -789,13 +734,7 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
content: '.log',
},
},
{
@@ -807,19 +746,7 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'World',
},
},
{
type: 'shiki',
token: {
content: '"',
content: '"World"',
},
},
{
@@ -1,39 +1,20 @@
import {
CustomizationCodeTheme,
type CustomizationThemedCodeTheme,
type DocumentBlockCode,
type DocumentBlockCodeLine,
type DocumentInlineAnnotation,
import type {
DocumentBlockCode,
DocumentBlockCodeLine,
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[];
@@ -55,12 +36,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: { ...bundledThemes, ...customThemes },
themes: {},
engine: () => createJavaScriptRegexEngine({ forgiving: true, target: 'ES2018' }),
})
);
@@ -68,15 +49,12 @@ const { getSingletonHighlighter } = createSingletonShorthands(
/**
* Preload the highlighter for a code block.
*/
export async function preloadHighlight(
block: DocumentBlockCode,
themes: CustomizationThemedCodeTheme = DEFAULT_THEMES
) {
export async function preloadHighlight(block: DocumentBlockCode) {
const langName = getBlockLang(block);
if (langName) {
await getSingletonHighlighter({
langs: [langName],
themes: [themes.light, themes.dark],
themes: [theme],
});
}
}
@@ -89,9 +67,8 @@ export async function highlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): Promise<HighlightTheme> {
): Promise<HighlightLine[]> {
const langName = getBlockLang(block);
if (!langName || (isSafari && ['powershell', 'cpp'].includes(langName))) {
@@ -103,20 +80,13 @@ 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: [themes.light, themes.dark],
themes: [theme],
});
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.
@@ -124,47 +94,38 @@ export async function highlight(
tokenizeMaxLineLength = 5000;
}
const result = highlighter.codeToTokens(code, {
const lines = highlighter.codeToTokensBase(code, {
lang: langName,
themes: resolvedThemes,
// Shiki's light-dark() CSS function provides different colors for light/dark modes based on the resolved themes
defaultColor: 'light-dark()',
theme,
tokenizeMaxLineLength,
});
const lines = result.tokens;
let currentIndex = 0;
return {
bg: result.bg,
fg: result.fg,
themes: resolvedThemes,
lines: lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
return 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;
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
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;
};
currentIndex += 1; // for the \n
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
}),
};
currentIndex += 1; // for the \n
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
});
}
/**
@@ -1,9 +1,8 @@
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import type { DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
@@ -13,59 +12,42 @@ export function plainHighlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): HighlightTheme {
): HighlightLine[] {
const inlinesCopy = Array.from(inlines);
// 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',
},
};
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
};
}
return {
themes: themes,
lines: block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
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),
};
}
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,
},
],
};
}),
};
});
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
});
}
@@ -71,8 +71,6 @@ 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}
@@ -87,7 +85,7 @@ export function DocumentViewSkeleton(props: { document: JSONDocument; blockStyle
const { document, blockStyle } = props;
return (
<div className="flex flex-col gap-4 layout-openapi:pl-12">
<div className="flex flex-col gap-4">
{document.nodes.map((block) => (
<BlockSkeleton
key={block.key!}
@@ -97,10 +95,6 @@ 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 { resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef } 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 { document, block, context } = props;
const { block, context } = props;
const resolved =
block.data.ref && context.contentContext
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext)
? await resolveContentRef(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 { resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef } from '@/lib/references';
import { getSpaceLanguage } from '@/intl/server';
import { Button, Link } from '../primitives';
@@ -12,17 +12,13 @@ import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { document, block, context } = props;
const { block, context } = props;
if (!context.contentContext) {
return null;
}
const contentRef = await resolveContentRefInDocument(
document,
block.data.ref,
context.contentContext
);
const contentRef = await resolveContentRef(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 { resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
@@ -81,14 +81,12 @@ async function ImageBlock(props: {
isEstimatedOffscreen: boolean;
withFrame?: boolean;
}) {
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
const { block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext
? resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null,
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
block.data.refDark && context.contentContext
? resolveContentRefInDocument(document, block.data.refDark, context.contentContext)
? resolveContentRef(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 { useSetSearchState } from '../Search';
import { useSearch } 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 = useSetSearchState();
const [, setSearchState] = useSearch();
const language = useLanguage();
const handleSubmit = (value: string) => {
@@ -1,4 +1,4 @@
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef, resolveContentRefFallback } 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 { document, inline, context, buttonProps } = props;
const { inline, context, buttonProps } = props;
if (!('ref' in inline.data)) return;
const resolved =
context.contentContext && inline.data.ref
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
? await resolveContentRef(inline.data.ref, context.contentContext)
: null;
const href =
@@ -2,22 +2,20 @@ import type { GitBookBaseContext } from '@/lib/context';
import type { DocumentInlineImage } from '@gitbook/api';
import assertNever from 'assert-never';
import { type ResolvedContentRef, resolveContentRefInDocument } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRef } 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 { document, inline, context, ancestorInlines } = props;
const { inline, context, ancestorInlines } = props;
const { size = 'original' } = inline.data;
const [src, darkSrc] = await Promise.all([
context.contentContext
? resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null,
context.contentContext ? resolveContentRef(inline.data.ref, context.contentContext) : null,
inline.data.refDark && context.contentContext
? resolveContentRefInDocument(document, inline.data.refDark, context.contentContext)
? resolveContentRef(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 { document, inline, context, ancestorInlines } = props;
const { inline, document, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, {
? await resolveContentRef(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,16 +182,8 @@ function getOrderedListItemPrefixContent(input: {
}): string {
const { parent, block } = input;
const start = parent.data.start ?? 1;
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);
const index = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const value = index + start;
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 { resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef } from '@/lib/references';
import type { InlineProps } from './Inline';
export async function Mention(props: InlineProps<DocumentInlineMention>) {
const { document, inline, context } = props;
const { inline, context } = props;
const resolved = context.contentContext
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, {
? await resolveContentRef(inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
@@ -42,17 +42,7 @@ export function getOpenAPIContext(args: {
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
},
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
{...codeProps}
themeKey="openapi"
themes={
context && 'customization' in context
? context.customization.styling.codeTheme.openapi
: undefined
}
/>
),
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
renderDocument: (documentProps) => (
<DocumentView
document={documentProps.document as JSONDocument}
@@ -527,7 +527,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
@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;
@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;
}
.openapi-response-examples-panel {
@@ -541,9 +541,7 @@
.openapi-panel pre,
.openapi-codesample pre,
.openapi-response-examples pre {
@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!
@apply bg-transparent border-none rounded-none shrink shadow-none;
}
.openapi-panel-heading {
@@ -1,13 +1,12 @@
import type { DocumentBlockReusableContent } from '@gitbook/api';
import { getDataOrNull } from '@/lib/data';
import { resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef } 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 { document, block, context, ancestorBlocks } = props;
const { block, context, ancestorBlocks } = props;
if (!context.contentContext) {
throw new Error('Expected a content context to render a reusable content block');
@@ -17,7 +16,7 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
: context.contentContext.dataFetcher;
const resolved = await resolveContentRefInDocument(document, block.data.ref, {
const resolved = await resolveContentRef(block.data.ref, {
...context.contentContext,
dataFetcher,
});
@@ -31,7 +30,7 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
return null;
}
const reusableContentDocument = await getDataOrNull(
const document = await getDataOrNull(
dataFetcher.getRevisionReusableContentDocument({
spaceId: reusableContent.context.space.id,
revisionId: reusableContent.context.revisionId,
@@ -39,14 +38,14 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
})
);
if (!reusableContentDocument) {
if (!document) {
return null;
}
return (
<UnwrappedBlocks
nodes={reusableContentDocument.nodes}
document={reusableContentDocument}
nodes={document.nodes}
document={document}
ancestorBlocks={[...ancestorBlocks, block]}
context={{
...context,
@@ -32,16 +32,7 @@ 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',
// 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={tcls('mx-auto flex w-full max-w-3xl flex-row gap-4 md:gap-8', 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, resolveContentRefInDocument } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRef } 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, document } = props;
const { view, record, context, block, isOffscreen } = 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
? resolveContentRefInDocument(document, light.contentRef, context.contentContext)
? resolveContentRef(light.contentRef, context.contentContext)
: null,
dark.contentRef && context.contentContext
? resolveContentRefInDocument(document, dark.contentRef, context.contentContext)
? resolveContentRef(dark.contentRef, context.contentContext)
: null,
targetRef && context.contentContext
? resolveContentRefInDocument(document, targetRef, context.contentContext)
? resolveContentRef(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 { resolveContentRefInDocument } from '@/lib/references';
import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
import type { BlockProps } from '../Block';
@@ -176,8 +176,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
const files = await Promise.all(
value.map((fileId) =>
context.contentContext
? resolveContentRefInDocument(
document,
? resolveContentRef(
{
kind: 'file',
file: fileId,
@@ -251,7 +250,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const resolved =
value && context.contentContext
? await resolveContentRefInDocument(document, value, context.contentContext, {
? await resolveContentRef(value, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['mr-2', 'text-tint-subtle'],
})
@@ -294,11 +293,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
user: userId,
};
const resolved = context.contentContext
? await resolveContentRefInDocument(
document,
contentRef,
context.contentContext
)
? await resolveContentRef(contentRef, context.contentContext)
: null;
if (!resolved) {
return null;
@@ -371,8 +366,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const image = context.contentContext
? await resolveContentRefInDocument(
document,
? await resolveContentRef(
'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={tcls('p-4')} hidden={!isActive}>
<div className="p-4" hidden={!isActive}>
{tab.body}
</div>
</div>
@@ -60,7 +60,8 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
style="[&>*:first-child]:!pt-0 flex flex-1 flex-col [&>*+*]:mt-5"
// 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"
/>
</div>
);
@@ -8,7 +8,6 @@ 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;
@@ -21,10 +20,8 @@ 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 && !socialLinks.length && hasThemeToggle;
const mobileOnly = !hasLogo && !hasGroups && !hasCopyright && hasThemeToggle;
return (
<footer
@@ -48,12 +45,16 @@ export function Footer(props: { context: GitBookSiteContext }) {
>
<div
className={tcls(
'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',
'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',
'grid-cols-[auto_auto]',
'@4xl:grid-cols-[18rem_minmax(auto,48rem)_auto]',
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
'@4xl:layout-full-width:grid-cols-[minmax(auto,90rem)_auto]',
'@7xl:layout-full-width:grid-cols-[14rem_minmax(auto,90rem)_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]'
)}
>
{
@@ -111,22 +112,10 @@ export function Footer(props: { context: GitBookSiteContext }) {
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'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'
'@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'
)}
>
<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'
)}
>
<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">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
@@ -148,21 +137,6 @@ 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 ? (
@@ -1,104 +0,0 @@
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'
: 'layout-full-width:hidden lg:hidden'
: 'page-no-toc:hidden lg:hidden'
)}
/>
<HeaderLogo context={context} />
@@ -36,39 +36,20 @@ export function PageAside(props: {
className={tcls(
'group/aside',
'order-last',
'hidden',
'max-w-0',
'pt-8',
'pb-4',
'opacity-0',
// 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!',
'xl:flex',
'overflow-hidden',
'max-w-0',
'opacity-0',
'xl:max-w-56',
'xl:opacity-11',
'xl:ml-12',
// 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',
@@ -104,7 +85,17 @@ 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)!'
'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'
)}
>
<div
@@ -113,19 +104,17 @@ export function PageAside(props: {
'min-w-56 shrink-0',
'overflow-hidden',
'w-full',
// 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'
'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'
)}
>
<PageAsideHeader context={context} />
@@ -147,10 +136,6 @@ 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);
@@ -159,7 +144,7 @@ function PageAsideHeader(props: { context: GitBookSiteContext }) {
<div
className={tcls(
'hidden',
'xl:layout-openapi:flex!',
'xl:max-2xl:page-api-block:flex!',
'text-xs',
'tracking-wide',
'font-semibold',
@@ -202,8 +187,7 @@ 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:layout-openapi:p-5',
'sidebar-list-default:px-3 pt-5 first:pt-0 xl:max-2xl:page-api-block:p-5',
'empty:hidden'
)}
>
@@ -225,7 +209,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:layout-openapi:border-t xl:layout-openapi:p-2',
'border-tint-subtle xl:max-2xl:page-api-block:border-t xl:max-2xl:page-api-block:p-2',
'pt-4'
)}
>
@@ -3,13 +3,7 @@ import type { JSONDocument, RevisionPageDocument, SiteInsightsDisplayContext } f
import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import {
hasFullWidthBlock,
hasMoreThan,
hasOpenAPIBlock,
hasTopLevelBlock,
isNodeEmpty,
} from '@/lib/document';
import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@/lib/document';
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
@@ -47,7 +41,6 @@ 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
@@ -62,7 +55,8 @@ 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;
@@ -71,20 +65,7 @@ export function PageBody(props: {
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
).length > 0;
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);
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
return (
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
@@ -92,28 +73,15 @@ 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',
// Layout mode class for CSS variants
layoutMode,
// Keep existing classes for backward compatibility
hasTOC ? 'page-has-toc' : 'page-no-toc',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
// Only apply page-width-wide in full-width mode
!hasTOC && page.layout.width === 'wide'
? 'page-width-wide 3xl:px-8'
: 'page-width-default'
pageHasToc ? 'page-has-toc' : 'page-no-toc'
)}
>
<PreservePageLayout
siteWidthWide={siteWidthWide}
layoutMode={layoutMode}
hasTOC={hasTOC}
/>
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
@@ -160,24 +128,7 @@ 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={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'
)}
>
<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">
{updatedAt ? (
<p className="mr-auto text-sm ">
{t(
@@ -79,9 +79,6 @@ 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,18 +83,22 @@ export async function PageCover(props: {
data-full={String(as === 'full')}
className={tcls(
'overflow-hidden',
'flex',
'w-full',
// Negative margin to balance the container padding
'-mx-4',
'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',
@@ -107,11 +111,6 @@ 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,11 +35,6 @@ 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,15 +52,11 @@ 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,26 +1,20 @@
'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 is done using CSS (`body:has(.layout-*)`),
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.full-width)`),
* which becomes false while transitioning between the 2 page states:
*
* 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
* 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
*
* 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;
layoutMode: (typeof LAYOUT_MODE_CLASSES)[number];
hasTOC: boolean;
}) {
const { siteWidthWide, layoutMode, hasTOC } = props;
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
const { siteWidthWide, pageHasToc } = props;
React.useLayoutEffect(() => {
// We use the header as it's an element preserved between page transitions
@@ -30,29 +24,20 @@ export function PreservePageLayout(props: {
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');
}
// Preserve page TOC state to prevent logo sizing issues during navigation
if (hasTOC) {
if (pageHasToc) {
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, layoutMode, hasTOC]);
}, [siteWidthWide, pageHasToc]);
return null;
}
@@ -34,7 +34,6 @@ 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') {
@@ -119,8 +118,6 @@ 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' ? (
@@ -138,7 +135,7 @@ export async function CustomizationRootLayout(props: {
undefined
}
>{`
:root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
:root {
${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)}
@@ -165,7 +162,7 @@ export async function CustomizationRootLayout(props: {
${generateColorVariable('success', successColor.light)}
}
.dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
.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 })}
@@ -1,38 +0,0 @@
'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,36 +296,88 @@ 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 text-tint-strong grid grid-cols-subgrid col-span-2 relative hover:invert-5 hover:z-1 rounded-sm;
@apply grid grid-cols-subgrid col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-1 rounded-sm;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
/* 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 bg-tint-active;
@apply first:rounded-t-md first:*:mt-1;
@apply last:rounded-b-md last:*:mb-1;
@apply rounded-none;
@@ -345,8 +397,12 @@ html.dark .shiki span {
}
.highlight-line-number {
@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;
@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;
}
}
.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 { useSearchLink, useSetSearchState } from './useSearch';
import { useSearch, useSearchLink } from './useSearch';
export type SearchAskState =
| {
@@ -130,7 +130,7 @@ function TransitionAnswerBody(props: {
}, [answer]);
const language = useLanguage();
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
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, useBodyLoaded } from '../primitives';
import { Popover } 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 { useSearchState, useSetSearchState } from './useSearch';
import { useSearch } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
@@ -60,14 +60,12 @@ export function SearchContainer({
}: SearchContainerProps) {
const { assistants, config } = useAI();
const state = useSearchState();
const setSearchState = useSetSearchState();
const [state, setSearchState] = useSearch();
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();
@@ -81,13 +79,11 @@ 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
// 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]);
assistants[0]?.open(state?.ask ?? undefined);
}, [state?.ask, assistants.length, assistants[0]?.open]);
const onClose = React.useCallback(
async (to?: string) => {
@@ -68,7 +68,9 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
className="size-text-lg shrink-0 text-tint theme-bold:text-header-link/8"
/>
}
onValueChange={onChange}
onChange={(event) => {
onChange(event.target.value);
}}
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 { useSearchState, useSetSearchState } from './useSearch';
import { useSearch } from './useSearch';
interface SearchScopeControlProps {
spaceTitle: string;
@@ -20,7 +20,8 @@ interface SearchScopeControlProps {
export function SearchScopeControl(props: SearchScopeControlProps) {
const { withVariants, withSections } = props;
const state = useSearchState();
const [state] = useSearch();
const language = useLanguage();
if (!state) {
return null;
@@ -32,7 +33,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
return (
<div className="flex items-center gap-1">
<SearchScopeTitle />
<span className="mr-1">{t(language, 'search_scope_title')}</span>
{withSections ? (
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
) : null}
@@ -44,16 +45,11 @@ 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 = useSetSearchState();
const [, setSearchState] = useSearch();
return (
<DropdownMenu
@@ -114,7 +110,7 @@ function SearchScopeVariantControl(props: SearchScopeControlProps & { isExtended
const { isExtended, spaceTitle, withSections } = props;
const language = useLanguage();
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
return (
<DropdownMenu
@@ -1,12 +1,6 @@
'use client';
import {
type Values,
parseAsBoolean,
parseAsString,
parseAsStringLiteral,
useQueryStates,
} from 'nuqs';
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
import React from 'react';
import type { LinkProps } from '../primitives';
@@ -42,24 +36,13 @@ 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);
/**
* Normalize the raw state to handle legacy formats.
* Context to share the search state updater so all consumers use the same instance.
*/
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 const SearchContext = React.createContext<{
state: SearchState | null;
setState: UpdateSearchState;
} | null>(null);
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
const { children } = props;
@@ -69,18 +52,38 @@ 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(() => normalizeRawState(rawState).q !== null);
const [open, setIsOpen] = React.useState(() => {
return rawState?.q !== null;
});
const state = React.useMemo<SearchState | null>(() => {
const normalized = normalizeRawState(rawState);
if (normalized.q === null && normalized.ask === null) {
if (rawState === null || (rawState.q === null && rawState.ask === null)) {
return null;
}
return {
query: normalized.q,
ask: normalized.ask,
scope: normalized.scope,
query: rawState.q,
ask: rawState.ask,
scope: rawState.scope,
open,
};
}, [rawState, open]);
@@ -113,29 +116,18 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
[setRawState]
);
return (
<SearchStateContext.Provider value={state}>
<SetSearchStateContext.Provider value={setState}>
{children}
</SetSearchStateContext.Provider>
</SearchStateContext.Provider>
);
return <SearchContext.Provider value={{ state, setState }}>{children}</SearchContext.Provider>;
}
export function useSetSearchState() {
const setState = React.useContext(SetSearchStateContext);
if (setState === undefined) {
throw new Error('useSetSearchState must be used within SearchContextProvider');
/**
* 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');
}
return setState;
}
export function useSearchState() {
const state = React.useContext(SearchStateContext);
if (state === undefined) {
throw new Error('useSearchState must be used within SearchContextProvider');
}
return state;
return [ctx.state, ctx.setState];
}
/**
@@ -145,7 +137,7 @@ export function useSearchLink(): (
params: Partial<SearchState>,
callback?: () => void
) => LinkProps {
const setSearchState = useSetSearchState();
const [, setSearchState] = useSearch();
return React.useCallback(
(params, callback) => {
@@ -71,13 +71,7 @@ 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 justify-start'
: 'contents'
}
>
<div className={withFullPageCover && page.cover ? 'flex grow flex-col' : 'contents'}>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
@@ -8,7 +8,6 @@ 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.
@@ -21,18 +20,11 @@ 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, ask, router, searchParams]);
}, [basePath, fallback, router]);
return (
<CurrentPageProvider page={null}>
@@ -53,7 +45,6 @@ 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,13 +105,11 @@ 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 (
@@ -129,7 +127,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
'lg:flex-row',
'lg:justify-center',
CONTAINER_STYLE,
'layout-full-width:max-w-screen-4xl',
'site-width-wide: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,10 +51,6 @@ 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,21 +144,22 @@ export const Button = React.forwardRef<
};
let iconElement = null;
if (icon) {
if (typeof icon === 'string') {
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 {
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,37 +32,15 @@ type CustomInputProps = {
* A keyboard shortcut, shown to the right of the input.
*/
keyboardShortcut?: boolean | KeyboardShortcutProps;
/**
* 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.
*/
onSubmit?: (value: string | number | readonly string[] | undefined) => void;
resize?: boolean;
};
export type InputProps = CustomInputProps &
(
| ({ multiline?: false } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange'
>)
| ({ multiline: true } & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'value' | 'onChange'
>)
| ({ multiline?: false } & React.InputHTMLAttributes<HTMLInputElement>)
| ({ multiline: true } & React.TextareaHTMLAttributes<HTMLTextAreaElement>)
);
type InputElement = HTMLInputElement | HTMLTextAreaElement;
@@ -94,7 +72,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
'aria-busy': ariaBusy,
placeholder,
disabled,
onValueChange,
onChange,
onKeyDown,
maxLength,
minLength,
@@ -102,7 +80,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
...htmlProps
} = props;
const [value, setValue] = useControlledState(passedValue, passedValue ?? '', onValueChange);
const [value, setValue] = useControlledState(passedValue, passedValue ?? '');
const [submitted, setSubmitted] = React.useState(false);
const [height, setHeight] = React.useState<number>();
const inputRef = React.useRef<InputElement>(null);
@@ -142,6 +120,7 @@ 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,19 +2,38 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import type * as React from 'react';
import * 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'
'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'
)}
>
{keys.map((key, index) => {
@@ -22,12 +41,7 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
switch (key) {
case 'mod':
element = (
<>
<span className="hidden [html.os-mac_&]:inline"></span>
<span className="inline [html.os-mac_&]:hidden">Ctrl</span>
</>
);
element = operatingSystem === 'mac' ? '⌘' : 'Ctrl';
break;
case 'enter':
@@ -39,7 +53,11 @@ 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' ? 'text-xs [html.os-mac_&]:text-sm' : 'uppercase',
key === 'mod'
? operatingSystem === 'mac'
? 'text-sm'
: 'text-xs'
: 'uppercase',
className
)}
>
@@ -18,17 +18,13 @@ export const NavigationLoader = () => {
void pathname;
const root = document.documentElement;
root.classList.add('route-change');
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
root.classList.remove('route-change');
});
return () => cancelAnimationFrame(raf2);
});
return () => {
root.classList.remove('route-change');
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
return () => cancelAnimationFrame(raf1);
}, [pathname]);
return (
@@ -33,7 +33,7 @@ export type ImageResponsiveSize = {
width: number;
};
const MAX_DPR = 3;
const MAX_DPR = 4;
interface ImageCommonProps {
/**
+6 -64
View File
@@ -10,7 +10,6 @@ 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';
@@ -52,19 +51,11 @@ export function createDataFetcher(
// API that are tied to the token
//
getPublishedContentSite(params) {
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'
);
return getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
});
},
getSiteRedirectBySource(params) {
return getSiteRedirectBySource(input, {
@@ -95,16 +86,6 @@ 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,
@@ -388,44 +369,6 @@ 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,
@@ -592,8 +535,7 @@ const getLatestOpenAPISpecVersionContent = cache(
const getPublishedContentSite = cache(
async (
input: DataFetcherInput,
params: { organizationId: string; siteId: string; siteShareKey: string | undefined },
_apiVersion: string
params: { organizationId: string; siteId: string; siteShareKey: string | undefined }
) => {
'use cache';
cacheTag(
-14
View File
@@ -32,20 +32,6 @@ 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,21 +41,19 @@ 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: CloudflareResizeImageOptions
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;
}
): Promise<Response> {
const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip && !options.bypassSkipCheck) {
+21 -82
View File
@@ -13,7 +13,6 @@ import {
import { type GitBookLinker, createLinker, linkerWithAbsoluteURLs } from '@/lib/links';
import type {
ContentRef,
JSONDocument,
RevisionFile,
RevisionPageDocument,
RevisionReusableContent,
@@ -78,42 +77,6 @@ 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.
*/
@@ -152,7 +115,7 @@ export async function resolveContentRef(
case 'anchor':
case 'page': {
if (isContentRefInDifferentSpace(contentRef, context)) {
if (contentRef.space && contentRef.space !== space.id) {
return resolveContentRefInSpace(contentRef.space, context, contentRef, options);
}
@@ -233,12 +196,13 @@ export async function resolveContentRef(
}
case 'space': {
const targetSpace = !isContentRefInDifferentSpace(contentRef, context)
? {
space: context.space,
siteSpace: 'siteSpace' in context ? context.siteSpace : null,
}
: await getBestTargetSpace(context, contentRef.space);
const targetSpace =
contentRef.space === context.space.id
? {
space: context.space,
siteSpace: 'siteSpace' in context ? context.siteSpace : null,
}
: await getBestTargetSpace(context, contentRef.space);
if (!targetSpace) {
return null;
@@ -274,12 +238,11 @@ 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 (!isContentRefInDifferentSpace(contentRef, context)) {
if (!contentRef.space || contentRef.space === context.space.id) {
return context;
}
@@ -343,16 +306,6 @@ 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
@@ -377,33 +330,10 @@ 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.
const inSite = getBestTargetSpaceFromSite(context, spaceId);
if (inSite) {
return inSite;
}
const { dataFetcher } = context;
const fetchedSpace = await getDataOrNull(
dataFetcher.getSpace({
spaceId,
shareKey: context?.shareKey,
}),
[404, 403]
);
// Else we try return the fetched space from the API.
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,
@@ -414,7 +344,16 @@ function getBestTargetSpaceFromSite(
}
}
return undefined;
const fetchedSpace = await getDataOrNull(
dataFetcher.getSpace({
spaceId,
shareKey: context?.shareKey,
}),
[404, 403]
);
// Else we try return the fetched space from the API.
return fetchedSpace ? { space: fetchedSpace, siteSpace: null } : undefined;
}
async function resolveContentRefInSpace(
-23
View File
@@ -1,23 +0,0 @@
/**
* 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,16 +19,6 @@ 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,
+18 -77
View File
@@ -9,22 +9,8 @@ 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.
*/
@@ -84,13 +70,10 @@ 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',
// For GIF, we will use webp as default format for resizing.
format: defaultFormat === 'gif' ? 'webp' : defaultFormat,
format: 'jpeg',
quality: 100,
};
@@ -118,54 +101,24 @@ export async function serveResizedImage(
// Check the Accept header to handle content negotiation
const accept = request.headers.get('accept');
// 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;
}
}
}
// 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);
}
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);
@@ -173,28 +126,16 @@ async function resizeImageWithFallback(
}
/**
* Get the original format from URL.
* 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.
*/
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);
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
// Ensure that the DPR is within the allowed range
return Math.max(1, Math.min(maxDprBySize, wantedDpr));
return Math.max(1, Math.min(maxDprBySize, clampedDpr));
}
/**
-10
View File
@@ -677,16 +677,6 @@ 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.
*/
+12 -5
View File
@@ -5,10 +5,17 @@ import url from 'node:url';
* Get the path to the kit, depending on the Pro or Free version.
*/
export function getKitPath() {
return path.resolve(
path.dirname(
url.fileURLToPath(import.meta.resolve('@gitbook/fontawesome-pro/package.json'))
),
'icons'
let source = path.dirname(
url.fileURLToPath(import.meta.resolve('@fortawesome/fontawesome-free/package.json'))
);
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,6 +17,9 @@
"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.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/json-magic": "^0.8.10",
"@scalar/openapi-parser": "^0.23.12",
"@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, hasValidServerHost } from './util/server';
import { getDefaultServerURL } from './util/server';
import {
resolvePrefillCodePlaceholderFromSecurityScheme,
resolveURLWithPrefillCodePlaceholdersFromServer,
@@ -216,14 +216,11 @@ 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 && !hasValidHost)) {
if (!validateHttpMethod(method) || (!hasMultipleMediaTypes && servers.length === 0)) {
return null;
}
@@ -240,7 +237,7 @@ function OpenAPICodeSampleFooter(props: {
) : (
<span />
)}
{!hideTryItPanel && hasValidHost && (
{!hideTryItPanel && servers.length > 0 && (
<ScalarApiButton
context={getOpenAPIClientContext(context)}
method={method}
@@ -18,14 +18,6 @@ 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,15 +31,9 @@ export function OpenAPIMediaTypeContent(props: {
const { stateKey, items, selectIcon, context } = props;
const state = useMediaTypesState(stateKey, items[0]?.key);
const item = items.find((item) => item.key === state.key) ?? items[0];
const examples = items.find((item) => item.key === state.key)?.examples ?? [];
if (!item) {
return null;
}
const examples = item.examples;
if (!examples || !examples.length) {
if (!items.length && !examples.length) {
return null;
}
+2 -9
View File
@@ -16,13 +16,7 @@ import { retrocycle } from './decycle';
import { getDisclosureLabel } from './getDisclosureLabel';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { tString } from './translate';
import {
checkIsReference,
getEffectiveArrayType,
getSchemaTitle,
resolveDescription,
resolveFirstExample,
} from './utils';
import { checkIsReference, getSchemaTitle, resolveDescription, resolveFirstExample } from './utils';
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
@@ -647,8 +641,7 @@ function getSchemaProperties(
discriminatorValue?: string | undefined
): null | OpenAPISchemaPropertyEntry[] {
// check array AND schema.items as this is sometimes null despite what the type indicates
const arrayInfo = getEffectiveArrayType(schema);
if (arrayInfo.isArray && schema.items && !checkIsReference(schema.items)) {
if (schema.type === 'array' && schema.items && !checkIsReference(schema.items)) {
const items = schema.items;
const itemProperties = getSchemaProperties(items, discriminator, discriminatorValue);
if (itemProperties) {
@@ -2,7 +2,6 @@ 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;
@@ -83,11 +82,8 @@ function getAdditionalItems(schema: OpenAPIV3.SchemaObject, context: OpenAPIClie
additionalItems += ` · ${tString(context.translation, 'max').toLowerCase()}: ${schema.maximum || schema.maxLength || schema.maxItems}`;
}
// 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()}`;
if (schema.nullable) {
additionalItems = ` | ${tString(context.translation, 'nullable').toLowerCase()}`;
}
return additionalItems;
+1 -7
View File
@@ -2,13 +2,7 @@ import clsx from 'classnames';
import { type ComponentPropsWithoutRef, forwardRef } from 'react';
export function Section(props: ComponentPropsWithoutRef<'div'>) {
return (
<div
{...props}
data-follow-color-scheme="true"
className={clsx('openapi-section', props.className)}
/>
);
return <div {...props} 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