mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 06:35:17 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d1bbfafe | |||
| dac5e9d444 |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Support code themes customization
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support social links
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Fix OpenAPI example not showing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/openapi-parser": patch
|
||||
---
|
||||
|
||||
Update scalar dependencies.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Improve OpenAPI server URL validation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix ordered list item index calculation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/icons": patch
|
||||
---
|
||||
|
||||
Simplify installation by using public fontawesome mirror.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
---
|
||||
|
||||
Add OpenAPI 3.1 nullable array support
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[install.scopes]
|
||||
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user