Compare commits

...

49 Commits

Author SHA1 Message Date
Zeno Kapitein 4228625852 Test 2026-02-10 18:23:19 +01:00
Zeno Kapitein e9c8ec79d2 Add inline set-claim action support 2026-02-10 18:13:07 +01:00
Greg Bergé 6ede6ee7c6 Add Korean translations (#3986) 2026-02-10 15:41:28 +01:00
Nolann B. 7a11861d36 Add support for custom cookie banner registration (#3925) 2026-02-10 11:28:24 +01:00
Greg Bergé 10754fbaf4 Fix GitBook icon not displayed in trademark (#3984) 2026-02-10 10:58:30 +01:00
Zeno Kapitein 8f32f4fb1e Better handle huge section groups (#3978) 2026-02-09 15:13:01 +01:00
Nolann B. 717ad0b300 Fix OpenAPI try it button justify (#3975) 2026-02-05 11:16:58 +01:00
Nolann B. a3eed52f24 Refine OpenAPI schema alternatives/allOf handling (#3973) 2026-02-05 11:00:59 +01:00
Greg Bergé 27a34b7ee2 Support defaultExpanded in Expandable block (#3974) 2026-02-04 11:49:14 +01:00
Zeno Kapitein bf3ad28c7f Add margin to TOC items after page group (#3972) 2026-02-03 16:10:32 +01:00
Zeno Kapitein 70c2e90e4b Handle empty page section search results (#3971) 2026-02-03 16:10:13 +01:00
Greg Bergé d0a378cdb6 Disallow Markdown pages crawling (#3970) 2026-02-03 09:40:17 +01:00
Viktor Renkema 81f8ddcf27 Fix flaky Updates blocks in visual tests (#3969) 2026-02-02 17:32:46 +01:00
Zeno Kapitein 9d6457f814 OpenAPI styling tweaks (#3968) 2026-01-30 19:02:30 +01:00
Zeno Kapitein 07c02ebcd2 Header & TOC styling tweaks (#3967) 2026-01-30 15:23:16 +01:00
Samy Pessé 4d3172d13f Rollout to 100% the dereferencing of reusable content at the API level (#3966) 2026-01-30 09:01:12 +01:00
Zeno Kapitein 0aa34cc9a8 Fix Embed options in React (#3964) 2026-01-29 17:32:56 +01:00
Greg Bergé 551d30cfa4 Fix loading of images in PDF export (#3958)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2026-01-29 15:55:42 +01:00
Samy Pessé 844a0a8ea7 Fix CI for visual testing 2026-01-29 15:26:41 +01:00
Samy Pessé eb8ba66a66 Ensure deployments to preview start when approval job is skipped 2026-01-29 14:06:48 +01:00
Hirotaka Miyagi d738e118a0 Fix: Prevent form submission during IME composition (#3942)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Addison <42930383+addisonschultz@users.noreply.github.com>
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2026-01-29 12:46:52 +00:00
Samy Pessé c16d08f5d5 Add approval step for external contributors' PRs (#3963) 2026-01-29 11:33:13 +01:00
Samy Pessé 61ec9b25b9 Enable approved PR deployments for preview workflows (#3954) 2026-01-29 11:16:00 +01:00
Samy Pessé b57561d395 Fix rendering of images in reusable content from an external space (#3960) 2026-01-29 10:42:25 +01:00
Zeno Kapitein e00d1c5bbd Support code themes customization option, refactor built-in code themes (#3949) 2026-01-28 13:48:56 +01:00
conico974 9063b56c89 Fix for the assistant (#3948) 2026-01-28 11:16:50 +01:00
Greg Bergé 020d3668d7 Make the button icon validation safer (#3955) 2026-01-28 10:44:52 +01:00
Greg Bergé 4d30cbf058 Fix social links not appearing on desktop (#3950) 2026-01-28 10:44:40 +01:00
Samy Pessé d3f6c38f05 Update scalar dependencies (#3956) 2026-01-28 10:29:56 +01:00
Samy Pessé 6f1db32682 Simplify local development and contributions (#3952) 2026-01-28 09:32:56 +01:00
Samy Pessé 4ad6b2c067 Increase rollout of reusable content deref in the API to 80% (#3951) 2026-01-28 09:22:19 +01:00
Viktor Renkema 07449443a0 Fix rendering of Expandables within Update blocks (#3947) 2026-01-27 20:26:33 +01:00
Greg Bergé b09cc64fec Fix GIF rendering (#3946) 2026-01-27 17:34:02 +01:00
Greg Bergé 1eed2004f6 Avoid keyboard-shortcut flicker by adding OS class and using CSS (#3944) 2026-01-27 17:02:49 +01:00
Greg Bergé 91539b8187 Fix navigation loader (#3945) 2026-01-27 17:01:06 +01:00
Samy Pessé 16cfde87ee Increase rollout of reusable content deref in the API to 50% (#3943) 2026-01-27 12:22:09 +01:00
conico974 58b4a978ff Update Next.js dependency to version 15.4.11 (#3940) 2026-01-27 09:26:11 +01:00
Viktor Renkema e34be0e89b Fix logo sizing bug during navigation (#3928) 2026-01-27 08:46:37 +01:00
Samy Pessé e60c754343 Increase rollout of reusable content deref in the API to 30% (#3939) 2026-01-26 16:22:13 +01:00
Nolann B. 48ca21e471 Add OpenAPI 3.1 nullable array support (#3938) 2026-01-26 14:05:14 +01:00
Nolann B. 3ba9e46f2b Improve OpenAPI server URL validation (#3937) 2026-01-26 13:45:03 +01:00
Greg Bergé 58f0cc8287 Fix search field clear button (#3936) 2026-01-24 16:26:03 +01:00
Nolann B. 29e2b22b63 Fix ordered list item index calculation (#3935) 2026-01-23 16:01:33 +01:00
Greg Bergé 3c7cb09c1b Respect DPR to ensure a good image quality (#3931) 2026-01-23 11:45:00 +01:00
Greg Bergé 0f1cd6745d Refactor search (#3932) 2026-01-23 11:13:20 +01:00
Samy Pessé e90f57b138 Deference reusable content at the API level (#3847) 2026-01-23 10:34:44 +01:00
conico974 7bd5939247 Bust data cache (#3927) 2026-01-22 13:59:35 +01:00
Zeno Kapitein 89de966e7a Support social accounts customization option (#3916) 2026-01-22 13:57:40 +01:00
Greg Bergé 4e2d86318e Fix OpenAPI example not showing (#3930)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 12:11:55 +01:00
132 changed files with 3283 additions and 702 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
OpenAPI styling tweaks
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Prevent form submission during IME composition
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": minor
"@gitbook/react-openapi": patch
---
Support code themes customization
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support social links
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Fix OpenAPI example not showing
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/openapi-parser": patch
---
Update scalar dependencies.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Support `defaultExpanded` in Expandable block
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Improve OpenAPI server URL validation
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Header & TOC tweaks
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ordered list item index calculation
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Fix Scalar Try it button justification
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix rendering of images in reusable content from an external space with the new dereferencing in the API.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/icons": patch
---
Simplify installation by using public fontawesome mirror.
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/browser-types": patch
"gitbook": patch
---
Add support for custom cookie banner registration
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Better handle huge section groups
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Fix Embed options in React
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/react-openapi": patch
---
Add OpenAPI 3.1 nullable array support
+2 -7
View File
@@ -5,8 +5,6 @@ on:
branches:
- main
- changeset-release/main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
runs-on: ubuntu-latest
@@ -36,13 +34,10 @@ jobs:
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
build:
runs-on: ubuntu-latest
name: Build (Open Source)
name: Build
timeout-minutes: 6
env:
BUN_NPM_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
+38 -7
View File
@@ -1,15 +1,25 @@
name: Preview
on:
pull_request:
pull_request_target:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
branches:
- main
jobs:
approval:
# Ensure that external contributors' PRs are manually approved before deployment
name: Approve Preview Deployment
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }}
environment:
name: preview-approval
steps:
- name: Approval gate
run: echo "Preview deployment approved."
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -18,6 +28,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Vercel
id: deploy
uses: ./.github/composite/deploy-vercel
@@ -31,6 +43,8 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
needs: approval
if: ${{ always() && (needs.approval.result == 'success' || needs.approval.result == 'skipped') }}
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
@@ -39,6 +53,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy to Cloudflare
id: deploy
uses: ./.github/composite/deploy-cloudflare
@@ -65,7 +81,7 @@ jobs:
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: always() && !startsWith(github.ref, 'refs/heads/main')
if: always() && github.event_name != 'push'
needs:
- deploy-v2-vercel
- deploy-v2-cloudflare
@@ -102,10 +118,13 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -123,10 +142,13 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -144,10 +166,13 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -165,10 +190,13 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
if: ${{ always() && needs.deploy-v2-cloudflare.result == 'success' }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -186,9 +214,12 @@ jobs:
runs-on: ubuntu-latest
name: Browserless Testing v2 (Vercel)
needs: deploy-v2-vercel
if: ${{ always() && needs.deploy-v2-vercel.result == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
@@ -217,4 +248,4 @@ jobs:
# run: cd ./packages/gitbook && bun e2e-browserless
# env:
# BASE_URL: ${{needs.deploy-v2-cloudflare.outputs.deployment-url}}
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
# SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
-2
View File
@@ -3,8 +3,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
-2
View File
@@ -3,8 +3,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
-3
View File
@@ -4,9 +4,6 @@ on:
push:
branches:
- main
env:
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
+1 -17
View File
@@ -77,23 +77,7 @@ All pull-requests will be tested against both visual and performances testing to
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
BUN_NPM_TOKEN=xxx
```
and then reinstall dependencies.
GitBook Open uses fontawesome. For self-hosting and local development, for licensing reasons, only the icons from the free version should be used.
## Contributing
+30 -37
View File
@@ -7,7 +7,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.4",
"turbo": "^2.7.6",
"vercel": "^39.4.2",
},
},
@@ -158,7 +158,7 @@
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"negotiator": "^1.0.0",
"next": "15.4.10",
"next": "15.4.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -176,7 +176,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
@@ -225,8 +225,8 @@
"gitbook-icons": "./bin/gitbook-icons.js",
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@gitbook/fontawesome-pro": "1.0.16",
},
"devDependencies": {
"@types/react": "catalog:",
@@ -235,9 +235,6 @@
"tsdown": "catalog:",
"typescript": "catalog:",
},
"optionalDependencies": {
"@gitbook/fontawesome-pro": "1.0.16",
},
"peerDependencies": {
"react": "*",
},
@@ -246,8 +243,8 @@
"name": "@gitbook/openapi-parser",
"version": "3.0.7",
"dependencies": {
"@scalar/json-magic": "^0.8.10",
"@scalar/openapi-parser": "^0.23.12",
"@scalar/json-magic": "^0.9.4",
"@scalar/openapi-parser": "^0.24.5",
"@scalar/openapi-types": "^0.5.3",
},
"devDependencies": {
@@ -348,7 +345,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.158.0",
"@gitbook/api": "0.159.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -725,11 +722,9 @@
"@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.1.0", "", {}, "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.1.0", "", {}, "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA=="],
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
"@gitbook/api": ["@gitbook/api@0.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="],
"@gitbook/api": ["@gitbook/api@0.159.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-IRZXT2NAcOlKlCcGnzRH4w82AuBXam5I7lw84qfCRbNDDycvFNBwIVMHu3JMOcGUTCkzj7QDElaNKYirsWDwFQ=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -875,7 +870,7 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="],
"@next/env": ["@next/env@15.4.10", "", {}, "sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg=="],
"@next/env": ["@next/env@15.4.11", "", {}, "sha512-mIYp/091eYfPFezKX7ZPTWqrmSXq+ih6+LcUyKvLmeLQGhlPtot33kuEOd4U+xAA7sFfj21+OtCpIZx0g5SpvQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A=="],
@@ -1289,23 +1284,23 @@
"@scalar/draggable": ["@scalar/draggable@0.2.0", "", { "dependencies": { "vue": "^3.5.12" } }, "sha512-UetHRB5Bqo5egVYlS21roWBcICmyk8CKh2htsidO+bFGAjl2e7Te+rY0borhNrMclr0xezHlPuLpEs1dvgLS2g=="],
"@scalar/helpers": ["@scalar/helpers@0.2.6", "", {}, "sha512-A471YFBCj7ZOlGIkAYnU8oYgeyts82ZNX+4UicrlmKv3eAQ+kwboN3Dy0R6u1lcA/+I/zzeXi/fBObsT7P9qTA=="],
"@scalar/helpers": ["@scalar/helpers@0.2.9", "", {}, "sha512-Y4ffJF0yELdwZ0BKgonqn3SumIgRn1WKyYCVHD+TDM7qRFChdGRypyt20+efHs26fmJeyBAIIv2laICj5uimiw=="],
"@scalar/icons": ["@scalar/icons@0.4.7", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^22.9.0", "chalk": "^5.4.1", "vue": "^3.5.17" } }, "sha512-0qXPGRdZ180TMfejWCPYy7ILszBrAraq4KBhPtcM12ghc5qkncFWWpTm5yXI/vrbm10t7wvtTK08CLZ36CnXlQ=="],
"@scalar/import": ["@scalar/import@0.4.31", "", { "dependencies": { "@scalar/helpers": "0.0.12", "@scalar/openapi-parser": "0.22.3", "yaml": "2.8.0" } }, "sha512-3+3dHz+EvS/C4YEQ8eEihZFVxRVZODsMEHsp48tvXTaIs1Qxb4kf3Nkaf9E3k+6rpM2CUrF59K1P8LvhXob48w=="],
"@scalar/json-magic": ["@scalar/json-magic@0.8.10", "", { "dependencies": { "@scalar/helpers": "0.2.6", "yaml": "^2.8.0" } }, "sha512-TWdKQ/hcy4erFQDp2MVlFoPesFep2VY96Q69cjLHmx5hxM0ZUBfmNB4lA8Uh3klgx5JmCDfSNIGjPFIpxlosUw=="],
"@scalar/json-magic": ["@scalar/json-magic@0.9.4", "", { "dependencies": { "@scalar/helpers": "0.2.9", "yaml": "^2.8.0" } }, "sha512-PyfyWrH4ZkW0TM1ColiiHj4NRF8hUM61H0UzAkHLhRNnKFxi6hI+oqNrwqPnyk93hrpkpTRHC7Fl5T0BRwuzVg=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.6.3", "", { "dependencies": { "@hyperjump/browser": "^1.1.0", "@hyperjump/json-schema": "^1.9.6", "@scalar/helpers": "0.1.1", "@scalar/json-magic": "0.8.1", "@scalar/object-utils": "1.2.11", "@scalar/openapi-types": "0.5.1", "@scalar/themes": "0.13.23", "@scalar/types": "0.4.0", "@scalar/workspace-store": "0.20.0", "@types/har-format": "^1.2.15", "flatted": "^3.3.3", "js-base64": "^3.7.8", "microdiff": "^1.5.0", "nanoid": "5.1.5", "type-fest": "5.0.0", "yaml": "2.8.0", "zod": "4.1.11" } }, "sha512-+hEMZSLuTeXlrPqpajusZn2R8qs/OrH2Xstllr3GOCnJz7ATwniD69DT7Il49L1j2HJEtX4EpleRX9OMhdoWHg=="],
"@scalar/object-utils": ["@scalar/object-utils@1.2.11", "", { "dependencies": { "@scalar/helpers": "0.1.1", "flatted": "^3.3.3", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1", "type-fest": "5.0.0" } }, "sha512-V44CbfjNuYhoVmVZ36DDlTfyBNF8dMYzLTcDz5zvoSsx+SViXE8ScMd+kgUMHCtSJZV6izV2x3UzIbGQY/pWsQ=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.23.12", "", { "dependencies": { "@scalar/json-magic": "0.8.10", "@scalar/openapi-types": "0.5.3", "@scalar/openapi-upgrader": "0.1.6", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.0" } }, "sha512-Znq9J8sXUe20YdbK2myoyeFNXgWTeZlyQUJeQu+wNsXAfWHGT68bF7aZzl99TsPHHVl3WmKDeSrWiuexfJ1u6A=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.24.5", "", { "dependencies": { "@scalar/json-magic": "0.9.4", "@scalar/openapi-types": "0.5.3", "@scalar/openapi-upgrader": "0.1.8", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.8.0" } }, "sha512-pTeKnmhVdSIfG3vysgDm6jsKc7Do1vXdy/4aqp7j8AEzXllf8RZjSgRSUhtvFYFQCr27fDZ117V3WPQUYtgmCw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.5.3", "", { "dependencies": { "zod": "^4.1.11" } }, "sha512-m4n/Su3K01d15dmdWO1LlqecdSPKuNjuokrJLdiQ485kW/hRHbXW1QP6tJL75myhw/XhX5YhYAR+jrwnGjXiMw=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.6", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-XdrNZUr0ASLfR89OS2zP6enbq9f7UGQQxov+a3WF1Wz9DClniAL2ChJ2fbGOrqL5F2kjbV6Fw/iO3bsBTMyLZA=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.1.8", "", { "dependencies": { "@scalar/openapi-types": "0.5.3" } }, "sha512-2xuYLLs0fBadLIk4I1ObjMiCnOyLPEMPf24A1HtHQvhKGDnGlvT63F2rU2Xw8lxCjgHnzveMPnOJEbwIy64RCg=="],
"@scalar/postman-to-openapi": ["@scalar/postman-to-openapi@0.3.40", "", { "dependencies": { "@scalar/helpers": "0.0.12", "@scalar/oas-utils": "0.5.2", "@scalar/openapi-types": "0.5.0" } }, "sha512-FP1p2/mb0Y5GM9hc+TI9ldDM44VV9GHwdhJQ8xGpArtlt8nxtyKmncOXcgayBD7qk3ohV6W1Eftsr258Eq7gGQ=="],
@@ -1325,17 +1320,17 @@
"@scalar/workspace-store": ["@scalar/workspace-store@0.20.0", "", { "dependencies": { "@scalar/code-highlight": "0.2.0", "@scalar/helpers": "0.1.1", "@scalar/json-magic": "0.8.1", "@scalar/openapi-upgrader": "0.1.4", "@scalar/snippetz": "0.5.2", "@scalar/themes": "0.13.23", "@scalar/typebox": "0.1.1", "@scalar/types": "0.4.0", "github-slugger": "^2.0.0", "type-fest": "5.0.0", "vue": "^3.5.21", "yaml": "2.8.0" } }, "sha512-RklQSKjz5V/69rKjxQV70q4u34qzmUZVtWGUJLVvHxPsPxCtxPPRLYY+gdQDkGjTQYol/dR9xyvi2QGk3OWTKA=="],
"@shikijs/core": ["@shikijs/core@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-+5dPz8q6HgNqfQ28ycm/vA8dIVd2lNFOUqVRFCQLbs0KZ6emYI+1apLpX+wuL/aDSPLOkMgARwNjkA5UjGKS1Q=="],
"@shikijs/core": ["@shikijs/core@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-1WrYfaz5YT5aTAIMbYQhxlSHc8ArX+hCDNAIdKRqJHzfWQ3xDgh3PTvrAly+RWGuvi5Q4NlvPlTBdlSAXN6Stg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-3V7ko+YUAP02I4rUbDjCgvyM/H85hUIZBQAS19FjDcJMKL5SbjWTiG7TRKxX1V4ddxLxt2RO64wZinElp/3ngQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ=="],
"@shikijs/langs": ["@shikijs/langs@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-Qze5YIsp223AmC69VZDQolcrcYPrVa9wV6cW2kVqsDrSWlwhW2EQZEn1Iw2oQU1tGYVg8Hj/xdp8mOv+9zI0vg=="],
"@shikijs/langs": ["@shikijs/langs@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA=="],
"@shikijs/themes": ["@shikijs/themes@3.2.0", "", { "dependencies": { "@shikijs/types": "3.2.0" } }, "sha512-XfzMSTu6iMl2FZIwKykld2OzFKDDlm4KbZrzW6sbKXEeJ1xq61HX4x4bE4+REBFqbbrvAQM8EAH11m/E3cxYDg=="],
"@shikijs/themes": ["@shikijs/themes@3.21.0", "", { "dependencies": { "@shikijs/types": "3.21.0" } }, "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw=="],
"@shikijs/types": ["@shikijs/types@3.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-1uOwfEO0vV+G8n/AO/6Yth7zshNdXvQ1pc4ygTrfE3cyuzVLukrZq72YkFUlsRijam7LvRTvnqL4aT5wx1X2Vw=="],
"@shikijs/types": ["@shikijs/types@3.21.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -1903,8 +1898,6 @@
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -2515,7 +2508,7 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["next@15.4.10", "", { "dependencies": { "@next/env": "15.4.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ=="],
"next": ["next@15.4.11", "", { "dependencies": { "@next/env": "15.4.11", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-IJRyXal45mIsshZI5XJne/intjusslUP1F+FHVBIyMGEqbYtIq1Irdx5vdWBBg58smviPDycmDeV6txsfkv1RQ=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -2557,9 +2550,9 @@
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.5.4", "", {}, "sha512-yNxcQ8sKvURiTwP0mV6bLQCYE7NKfKRRWunhbZnXgxSmB1OXa1lHrN3o4DZd+0Si0kU5blidK7BcROO8qv5TZA=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-to-es": ["oniguruma-to-es@4.1.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "oniguruma-parser": "^0.5.4", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-SNwG909cSLo4vPyyPbU/VJkEc9WOXqu2ycBlfd1UCXLqk1IijcQktSBb2yRQ2UFPsDhpkaf+C1dtT3PkLK/yWA=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
"openapi-fetch": ["openapi-fetch@0.13.5", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-AQK8T9GSKFREFlN1DBXTYsLjs7YV2tZcJ7zUWxbjMoQmj8dDSFRrzhLCbHPZWA1TMV3vACqfCxLEZcwf2wxV6Q=="],
@@ -2779,7 +2772,7 @@
"shell-quote": ["shell-quote@1.8.1", "", {}, "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA=="],
"shiki": ["shiki@3.2.0", "", { "dependencies": { "@shikijs/core": "3.2.0", "@shikijs/engine-javascript": "3.2.0", "@shikijs/engine-oniguruma": "3.2.0", "@shikijs/langs": "3.2.0", "@shikijs/themes": "3.2.0", "@shikijs/types": "3.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lOF6wkvZCRVQrdfGilyXclTKIjCWKujPAjD6fddLwtQ6eSmgj43pFDbjUmxivtElDRlsGO8G2dLeeRpwNY4wWg=="],
"shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="],
"side-channel": ["side-channel@1.0.6", "", { "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" } }, "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA=="],
@@ -2913,19 +2906,19 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.7.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.4", "turbo-darwin-arm64": "2.7.4", "turbo-linux-64": "2.7.4", "turbo-linux-arm64": "2.7.4", "turbo-windows-64": "2.7.4", "turbo-windows-arm64": "2.7.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g=="],
"turbo": ["turbo@2.7.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.6", "turbo-darwin-arm64": "2.7.6", "turbo-linux-64": "2.7.6", "turbo-linux-arm64": "2.7.6", "turbo-windows-64": "2.7.6", "turbo-windows-arm64": "2.7.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-PO9AvJLEsNLO+EYhF4zB+v10hOjsJe5kJW+S6tTbRv+TW7gf1Qer4mfjP9h3/y9h8ZiPvOrenxnEgDtFgaM5zw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDR30ltfkSsRfGzABBckvl1nz1cZ3ssTujvdj+TPwOweeDRvZ0e06t5DS0rmRBvyKpgGs42K/EK6Mn2qLlFY9A=="],
"turbo-darwin-64": ["turbo-darwin-64@2.7.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-bYu0qnWju2Ha3EbIkPCk1SMLT3sltKh1P/Jy5FER6BmH++H5z+T5MHh3W1Xoers9rk4N1VdKvog9FO1pxQyjhw=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7sjqXtOL/+nYWPvcDGWhi8wf8M8mZHHB8XEzw2VX7VJrS8IGHyJHGD1AYfDvhAEcr7pnk3gGifz3/xyhI655w=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KCxTf3Y1hgNLYIWRLw8bwH8Zie9RyCGoxAlXYsCBI/YNqBSR+ZZK9KYzFxAqDaVaNvTwLFv3rJRGsXOFWg4+Uw=="],
"turbo-linux-64": ["turbo-linux-64@2.7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GofFOxRO/IhG8BcPyMSSB3Y2+oKQotsaYbHxL9yD6JPb20/o35eo+zUSyazOtilAwDHnak5dorAJFoFU8MIg2A=="],
"turbo-linux-64": ["turbo-linux-64@2.7.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vjoU8zIfNgvJR3cMitgw7inEoi6bmuVuFawDl5yKtxjAEhDktFdRBpGS3WojD4l3BklBbIK689ssXcGf21LxRA=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+RQKgNjksVPxYAyAgmDV7w/1qj++qca+nSNTAOKGOfJiDtSvRKoci89oftJ6anGs00uamLKVEQ712TI/tfNAIw=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TcMpBvTqZf+1DptrVYLbZls7WY1UVNDTGaf0bo7/GCgWYv5eZHCVo4Td7kCJeDU4glbXg67REX0md0S0V6ghMg=="],
"turbo-windows-64": ["turbo-windows-64@2.7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-rfak1+g+ON3czs1mDYsCS4X74ZmK6gOgRQTXjDICtzvR4o61paqtgAYtNPofcVsMWeF4wvCajSeoAkkeAnQ1kg=="],
"turbo-windows-64": ["turbo-windows-64@2.7.6", "", { "os": "win32", "cpu": "x64" }, "sha512-1/MhkYldiihjneY8QnnDMbAkHXn/udTWSVYS94EMlkE9AShozsLTTOT1gDOpX06EfEW5njP09suhMvxbvwuwpQ=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-1ZgBNjNRbDu/fPeqXuX9i26x3CJ/Y1gcwUpQ+Vp7kN9Un6RZ9kzs164f/knrjcu5E+szCRexVjRSJay1k5jApA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-0wDVnUJLFAWm4ZzOQFDkbyyUqaszorTGf3Rdc22IRIyJTTLd6ajqdb+cWD89UZ1RKr953+PZR1gqgWQY4PDuhA=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
-2
View File
@@ -1,2 +0,0 @@
[install.scopes]
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+3 -3
View File
@@ -7,10 +7,10 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.8",
"turbo": "^2.7.4",
"turbo": "^2.7.6",
"vercel": "^39.4.2"
},
"packageManager": "bun@1.3.2",
"packageManager": "bun@1.3.7",
"overrides": {
"@codemirror/state": "6.4.1",
"@types/react": "catalog:",
@@ -41,7 +41,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.158.0",
"@gitbook/api": "0.159.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+17
View File
@@ -46,6 +46,11 @@ export type GitBookAssistant = {
ui?: boolean;
};
export type GitBookCookieBannerHandler = (options: {
onApprove: () => void;
onReject: () => void;
}) => void;
export type GitBookGlobal = {
/**
* Register an event listener.
@@ -72,6 +77,18 @@ export type GitBookGlobal = {
* Register a custom assistant to be available on the site.
*/
registerAssistant: (assistant: GitBookAssistant) => () => void;
/**
* Register a custom cookie banner handler.
* When registered, the built-in cookie banner will not be displayed.
*/
registerCookieBanner: (handler: GitBookCookieBannerHandler) => void;
/**
* Whether the user has granted cookie consent.
* Returns true if cookies are granted, false if rejected or unknown.
*/
hasApprovedCookies: () => boolean;
};
declare global {
+12 -4
View File
@@ -11,13 +11,21 @@ import { useGitBook } from './GitBookProvider';
export type GitBookFrameProps = {
className?: string;
} & GetFrameURLOptions &
GitBookEmbeddableConfiguration;
Partial<GitBookEmbeddableConfiguration>;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, actions, greeting, suggestions, tools } = props;
const {
className,
visitor,
actions = [],
greeting,
suggestions = [],
tools = [],
tabs = ['assistant', 'docs'],
} = props;
const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
@@ -33,13 +41,13 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => {
gitbookFrame?.configure({
tabs: ['assistant', 'docs'],
tabs,
actions,
greeting,
suggestions,
tools,
});
}, [gitbookFrame, actions, greeting, suggestions, tools]);
}, [gitbookFrame, actions, greeting, suggestions, tools, tabs]);
return (
<iframe
@@ -0,0 +1,52 @@
import { expect } from '@playwright/test';
import { type TestsCase, getCustomizationURL, runTestCases } from './util';
const testCases: TestsCase[] = [
{
name: 'Cookie Banner',
contentBaseURL: 'https://gitbook.com/docs/',
tests: [
{
name: 'should show built-in banner when no custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Check that built-in banner is visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
},
},
{
name: 'should not show built-in banner when custom banner is registered',
url: getCustomizationURL({
privacyPolicy: {
url: 'https://policies.gitbook.com/privacy/cookies',
},
}),
screenshot: false,
run: async (page) => {
// Register a custom cookie banner handler
await page.waitForFunction(() => {
return typeof window !== 'undefined' && window.GitBook !== undefined;
});
await page.evaluate(() => {
window.GitBook?.registerCookieBanner(() => {
// Custom cookie banner handler - no-op for testing to avoid reload
});
});
// Check that built-in banner is not visible
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
},
},
],
},
];
runTestCases(testCases);
+41
View File
@@ -8,6 +8,7 @@ import {
CustomizationIconsStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { GitBookStandalone } from '@gitbook/embed';
import { expect } from '@playwright/test';
@@ -1123,6 +1124,16 @@ const testCases: TestsCase[] = [
},
]),
},
{
name: 'Reusable contents',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/reusable-contents/',
tests: [
{
name: 'All cases',
url: '',
},
],
},
{
name: 'Page actions',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1139,6 +1150,36 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Social links',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'With social links',
url: getCustomizationURL({
socialAccounts: [
{
platform: SiteSocialAccountPlatform.Github,
display: { footer: true },
handle: 'GitbookIO',
},
{
platform: SiteSocialAccountPlatform.Linkedin,
display: { footer: true },
handle: 'gitbook',
},
{
platform: SiteSocialAccountPlatform.Twitter,
display: { footer: false },
handle: 'GitBookIO',
},
],
}),
fullPage: true,
run: waitForCookiesDialog,
},
],
},
{
name: 'Ads',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
+11
View File
@@ -2,6 +2,7 @@ import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationAIMode,
CustomizationBackground,
CustomizationCodeTheme,
CustomizationCorners,
CustomizationDefaultFont,
CustomizationDefaultMonospaceFont,
@@ -298,6 +299,16 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
links: CustomizationLinksStyle.Default,
codeTheme: {
default: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
openapi: {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
},
},
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
+3 -3
View File
@@ -49,7 +49,7 @@
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.24",
"next": "15.4.10",
"next": "15.4.11",
"next-themes": "^0.4.6",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -67,7 +67,7 @@
"remark-rehype": "^11.1.1",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^3.2.0",
"shiki": "^3.21.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.5",
@@ -120,7 +120,7 @@
"dev:cloudflare": "wrangler dev --port 8771 --env preview",
"dev:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
"dev:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
"e2e": "playwright test e2e/internal.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e": "playwright test e2e/internal.spec.ts e2e/cookie-banner.spec.ts e2e/pdf.spec.ts --project=chromium",
"e2e-customers": "playwright test e2e/customers.spec.ts --project=chromium",
"unit": "bun test {src,packages} --preload ./tests/preload-bun.ts",
"e2e-browserless": "bun test ./tests/",
+2 -2
View File
@@ -10,7 +10,7 @@ import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch';
import { useSetSearchState } from '../Search/useSearch';
// Unify assistants configuration context with the assistants hook in one place
export type AIConfig = {
@@ -84,7 +84,7 @@ export function useAI(): AIContext {
const chat = useAIChatState();
const chatController = useAIChatController();
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const assistants: Assistant[] = [];
@@ -13,7 +13,7 @@ import type { IconName } from '@gitbook/icons';
import * as React from 'react';
import { useTrackEvent } from '../Insights';
import { integrationsAssistantTools } from '../Integrations';
import { useSearch } from '../Search';
import { useSetSearchState } from '../Search';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { useAIMessageContextRef } from './useAIMessageContext';
@@ -163,7 +163,7 @@ export function AIChatProvider(props: {
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const language = useLanguage();
// Event listeners storage
@@ -14,6 +14,10 @@ const adaptiveVisitorReaderCache = new Map<
ReturnType<typeof createResourceReader<AdaptiveVisitorClaims | null>>
>();
export function clearAdaptiveVisitorClaimsCache() {
adaptiveVisitorReaderCache.clear();
}
function createResourceReader<T>(promise: Promise<T>) {
let result: T | null | undefined;
@@ -6,6 +6,7 @@ import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import { useCustomCookieBanner, useIntegrationsLoaded } from '@/components/Integrations';
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
/**
@@ -15,10 +16,18 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
const { privacyPolicy = 'https://policies.gitbook.com/privacy/cookies' } = props;
const [show, setShow] = React.useState(false);
const language = useLanguage();
const integrationsLoaded = useIntegrationsLoaded();
const { hasCustomCookieBanner } = useCustomCookieBanner();
React.useEffect(() => {
// Always wait for integrations to load, and if a custom banner is registered, hide the built-in banner
if (!integrationsLoaded || hasCustomCookieBanner) {
setShow(false);
return;
}
setShow(isCookiesTrackingDisabled() === undefined);
}, []);
}, [hasCustomCookieBanner, integrationsLoaded]);
if (!show) {
return null;
@@ -7,18 +7,18 @@ import {
import { Card, type CardProps } from '@/components/primitives';
import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props;
const { document, block, context, style } = props;
const resolved = context.contentContext
? await resolveContentRef(block.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['text-xl', 'text-tint'],
})
@@ -1,6 +1,6 @@
'use client';
import type { DocumentBlockCode } from '@gitbook/api';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive';
@@ -8,18 +8,19 @@ import { useInViewportListener } from '@/components/hooks/useInViewportListener'
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button, ToggleChevron } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { type ClassValue, tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import type { HighlightLine, RenderedInline } from './highlight';
import type { HighlightTheme, RenderedInline } from './highlight';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
themes?: CustomizationThemedCodeTheme;
};
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
@@ -29,7 +30,7 @@ export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, mode, style, inlines, inlineExprVariables } = props;
const { block, mode, style, inlines, inlineExprVariables, themes } = props;
const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false);
@@ -40,17 +41,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
visitorClaims,
variables: inlineExprVariables,
});
const plainLines = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression }),
[block, inlines, evaluateInlineExpression]
const plainTheme = useMemo(
() => plainHighlight(block, inlines, { evaluateInlineExpression, themes }),
[block, inlines, evaluateInlineExpression, themes]
);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [theme, setTheme] = useState<null | HighlightTheme>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted.
useEffect(() => {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block, themes));
}, [block, themes]);
// When user scrolls, we need to wait for the scroll to finish before running the highlight
const isScrollingRef = useRef(false);
@@ -99,14 +100,16 @@ export function ClientCodeBlock(props: ClientBlockProps) {
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines, { evaluateInlineExpression }).then((lines) => {
if (cancelled) {
return;
}
highlight(block, inlines, { evaluateInlineExpression, themes }).then(
(theme) => {
if (cancelled) {
return;
}
setLines(lines);
setHighlighting(false);
});
setTheme(theme);
setHighlighting(false);
}
);
});
}
@@ -116,12 +119,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}
// Otherwise if the block is not in viewport, we reset to the plain lines
setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]);
setTheme(null);
}, [isInViewport, block, inlines, evaluateInlineExpression, themes]);
const expandable = block.data.expandable;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const numberOfLinesOfCode = theme?.lines.length ?? plainTheme.lines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
@@ -136,16 +139,17 @@ export function ClientCodeBlock(props: ClientBlockProps) {
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
theme={theme ?? plainTheme}
id={codeBlockBodyId}
/>
);
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
theme={theme ?? plainTheme}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
style={style}
>
{renderer}
</CodeBlockExpandable>
@@ -156,21 +160,25 @@ export function ClientCodeBlock(props: ClientBlockProps) {
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
theme: HighlightTheme;
collapsedLineCount: number;
controls?: string;
style?: ClassValue;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const { children, controls, theme, collapsedLineCount, style } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls('group/codeblock-expandable relative', style)}
data-follow-color-scheme="true"
aria-expanded={isExpanded}
>
<div
className={tcls(
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
!isExpanded
? '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden'
: ''
)}
style={
{
@@ -181,7 +189,7 @@ function CodeBlockExpandable(props: {
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<div className="pointer-events-none absolute bottom-2 flex w-full justify-center">
<Button
icon={<ToggleChevron open={isExpanded} />}
size="xsmall"
@@ -194,7 +202,7 @@ function CodeBlockExpandable(props: {
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
: t(language, 'code_block_collapsed', theme.lines.length)}
</Button>
</div>
</div>
@@ -1,6 +1,10 @@
import * as React from 'react';
import type { DocumentBlockCode } from '@gitbook/api';
import type {
CustomizationThemedCodeTheme,
DocumentBlockCode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document';
@@ -13,8 +17,21 @@ import { type RenderedInline, getInlines, highlight } from './highlight';
/**
* Render a code block, can be client-side or server-side.
*/
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, isEstimatedOffscreen, context } = props;
export async function CodeBlock(
props: BlockProps<DocumentBlockCode> & {
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}
) {
const {
block,
document,
style,
isEstimatedOffscreen,
context,
themeKey = 'default',
themes: providedThemes,
} = props;
const inlines = getInlines(block);
let hasInlineExpression = false;
@@ -50,10 +67,19 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body };
});
// Get code themes from customization
const themes =
providedThemes ??
(context.contentContext && 'customization' in context.contentContext
? context.contentContext.customization.styling.codeTheme[themeKey]
: undefined);
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
// In v2, we render the code block server-side
const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
const theme = await highlight(block, richInlines, {
themes: themes,
});
return <CodeBlockRenderer block={block} style={style} theme={theme} />;
}
const variables = context.contentContext
@@ -74,6 +100,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
inlines={richInlines}
inlineExprVariables={variables}
mode={context.mode}
themes={themes}
/>
</React.Suspense>
);
@@ -7,10 +7,10 @@ import { tcls } from '@/lib/tailwind';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import type { HighlightLine, HighlightTheme, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
theme: HighlightTheme;
'aria-busy'?: boolean;
id?: string;
};
@@ -22,7 +22,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const { block, style, theme, 'aria-busy': ariaBusy } = props;
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
@@ -30,15 +30,35 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const id = useId();
const codeId = props.id || id;
/* Shiki returns a color + CSS variables in a single string, which isn't supported in React.
So we parse the foreground & background into objects that can be passed into React's `style` prop. */
const bg = parseShikiColorString(theme.bg);
const fg = parseShikiColorString(theme.fg);
return (
<div
ref={ref}
aria-busy={ariaBusy}
className={tcls('group/codeblock grid shrink grid-flow-col overflow-hidden', style)}
className={tcls(
'group/codeblock shiki grid shrink grid-flow-col overflow-hidden',
style
)}
/* Sets the code theme's mode (light or dark) for the site's theme mode (light or dark).
* Used to style UI elements (scrollbars, form controls) correctly and apply the right default to "plain" code blocks. */
data-color-scheme={`${theme.themes.light.type} ${theme.themes.dark.type}`}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? (
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
<div
className="relative top-px z-20 inline-flex items-center justify-center circular-corners:rounded-t-xl rounded-corners:rounded-t-lg straight-corners:rounded-t-xs border border-tint-subtle border-b-0 bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
{title}
</div>
) : null}
@@ -49,23 +69,32 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/>
<pre
className={tcls(
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base p-2 [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
'rounded-md straight-corners:rounded-xs shadow-xs',
title && 'rounded-ss-none'
'relative overflow-auto border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base p-2 text-tint-strong [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base',
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs depth-subtle:shadow-xs',
title && 'rounded-ss-none!'
)}
style={{
backgroundColor: bg?.color,
...bg?.vars,
color: fg?.color,
...fg?.vars,
}}
>
<code
id={codeId}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
'inline-grid max-h-full min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap',
'[[aria-expanded=false]_&]:mask-b-from-50%'
)}
>
{lines.map((line, index) => (
{theme.lines.map((line, index) => (
<CodeHighlightLine
bg={bg}
fg={fg}
key={index}
line={line}
isLast={index === lines.length - 1}
isLast={index === theme.lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
@@ -77,13 +106,33 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
function CodeHighlightLine(props: {
line: HighlightLine;
bg?: ShikiColorDefinition;
fg?: ShikiColorDefinition;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
const { line, isLast, withLineNumbers, bg, fg } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers && <span className="highlight-line-number" />}
<span
className={tcls('highlight-line', line.highlighted && 'highlighted')}
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
>
{withLineNumbers && (
<span
className="highlight-line-number"
style={{
color: fg?.color,
...fg?.vars,
backgroundColor: bg?.color,
...bg?.vars,
}}
/>
)}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
@@ -112,13 +161,53 @@ function CodeHighlightToken(props: { token: HighlightToken }) {
return token.content;
}
case 'shiki': {
if (!token.token.color) {
if (!token.token.htmlStyle) {
return token.token.content;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
return <span style={token.token.htmlStyle}>{token.token.content}</span>;
}
default:
assertNever(token);
}
}
type ShikiColorDefinition = {
color: string | undefined;
vars: Record<string, string> | undefined;
};
/**
* Parse Shiki color string format into separate color and CSS variables.
*
* Shiki returns colors in a format that combines a default color with CSS variables:
* "defaultColor;--shiki-light:value1;--shiki-dark:value2"
*
* React does not parse these strings as valid CSS because they contain values & properties in one.
* We split these so React can apply them separately via the style prop.
*/
function parseShikiColorString(
originalColor: string | undefined
): ShikiColorDefinition | undefined {
if (!originalColor) {
return undefined;
}
const parts = originalColor.split(';');
const color = parts[0];
const vars = parts.slice(1).reduce(
(acc, item) => {
const [key, value] = item.split(':');
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
return {
color,
vars: Object.keys(vars).length > 0 ? vars : undefined,
};
}
@@ -1,4 +1,8 @@
import type { JSONDocument } from '@gitbook/api';
import type {
CustomizationThemedCodeTheme,
JSONDocument,
SiteCustomizationSettings,
} from '@gitbook/api';
import { useId } from 'react';
import { CodeBlock } from './CodeBlock';
@@ -8,8 +12,13 @@ import { convertCodeStringToBlock } from './utils';
* Plain code block with syntax highlighting.
* For simplicity, this is just a wrapper around the CodeBlock component, emulating a document.
*/
export function PlainCodeBlock(props: { code: string; syntax: string }) {
const { code, syntax } = props;
export function PlainCodeBlock(props: {
code: string;
syntax: string;
themeKey?: keyof SiteCustomizationSettings['styling']['codeTheme'];
themes?: CustomizationThemedCodeTheme;
}) {
const { code, syntax, themeKey, themes } = props;
const id = useId();
const block = convertCodeStringToBlock({ key: id, code, syntax });
@@ -26,6 +35,8 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
context={{
mode: 'default',
}}
themeKey={themeKey}
themes={themes}
block={block}
ancestorBlocks={[]}
// We optimize perf by default
@@ -0,0 +1,832 @@
import type { ThemeRegistration } from 'shiki/core';
export const customThemes: Record<string, ThemeRegistration> = {
'default-light': createTheme({
name: 'default-light',
type: 'light',
bg: 'inherit',
fg: 'inherit',
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: 'inherit',
fg: 'inherit',
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: 'inherit',
fg: 'inherit',
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: 'inherit',
fg: 'inherit',
colors: {
basic: {
comment: 'rgb(var(--tint-9))',
string: 'rgb(var(--primary-12))',
constant: 'rgb(var(--primary-11))',
escape: 'rgb(var(--tint-12))',
},
keyword: {
default: 'rgb(var(--primary-11))',
operator: 'rgb(var(--primary-11))',
regexpOperator: 'rgb(var(--primary-10))',
regexpQuantifier: 'rgb(var(--primary-11))',
},
variable: {
default: 'rgb(var(--tint-12))',
language: 'rgb(var(--primary-10))',
parameter: 'rgb(var(--tint-9))',
},
function: {
default: 'rgb(var(--primary-9))',
support: 'rgb(var(--primary-9))',
},
type: {
custom: 'rgb(var(--primary-10))',
primitive: 'rgb(var(--primary-11))',
other: 'rgb(var(--primary-10))',
classMeta: 'rgb(var(--tint-12))',
},
entity: {
tag: 'rgb(var(--primary-11))',
attributeId: 'rgb(var(--primary-9))',
label: 'rgb(var(--primary-10))',
},
markup: {
default: 'rgb(var(--primary-10))',
code: 'rgb(var(--tint-12))',
link: 'rgb(var(--primary-10))',
linkUrl: 'rgb(var(--primary-11))',
separator: 'rgb(var(--tint-7))',
},
diff: {
inserted: 'rgb(var(--success-11))',
deleted: 'rgb(var(--danger-11))',
changed: 'rgb(var(--tint-12))',
},
other: {
punctuation: 'rgb(var(--tint-10))',
preprocessor: 'rgb(var(--primary-10))',
default: 'rgb(var(--primary-11))',
invalid: 'rgb(var(--danger-11))',
},
},
}),
};
/**
* Color category mappings for syntax highlighting.
* Organized into logical groups for better readability.
*/
export type ColorCategories = {
/** Basic syntax elements */
basic: {
/** Comments (//, /*, #, etc.) */
comment: string;
/** String literals ("...", '...', `...`) */
string: string;
/** Constants: numeric, language constants (true, false, null), character constants */
constant: string;
/** Escape sequences in strings (\n, \t, etc.) */
escape: string;
};
/** Keywords and operators */
keyword: {
/** Control flow (if, else, for, while, return), storage (const, let, var, function, class) */
default: string;
/** Operators (+, -, *, /, =, ==, etc.) */
operator: string;
/** Regexp operators (|, ^, $) */
regexpOperator: string;
/** Regexp quantifiers (*, +, ?, {n}) */
regexpQuantifier: string;
};
/** Variables and parameters */
variable: {
/** Regular variables */
default: string;
/** Language variables (this, self, super) */
language: string;
/** Function/method parameters */
parameter: string;
};
/** Functions */
function: {
/** User-defined function/method invocations */
default: string;
/** Built-in/library support functions */
support: string;
};
/** Types and classes */
type: {
/** Custom type names (DocumentBlockCode, HighlightLine, etc.) */
custom: string;
/** Primitive types (number, boolean, string, etc.) */
primitive: string;
/** Other types (support.type, support.class, namespaces) */
other: string;
/** Class meta contexts (meta.class) */
classMeta: string;
};
/** Entities (tags, attributes, labels) */
entity: {
/** HTML/XML tags and attributes */
tag: string;
/** Attribute IDs (entity.other.attribute-name.id) */
attributeId: string;
/** Labels (entity.name.label) */
label: string;
};
/** Markup elements */
markup: {
/** Markup elements (bold, italic, headings, lists, quotes, etc.) */
default: string;
/** Inline code in markup */
code: string;
/** Links in markup */
link: string;
/** Link URLs in markup */
linkUrl: string;
/** Separators (meta.separator) */
separator: string;
};
/** Diff highlighting */
diff: {
/** Inserted lines */
inserted: string;
/** Deleted lines */
deleted: string;
/** Changed lines */
changed: string;
};
/** Other syntax elements */
other: {
/** Punctuation (., ,, ;, :, {}, [], (), etc.) */
punctuation: string;
/** Preprocessor directives (#include, #define, etc.) */
preprocessor: string;
/** Other/miscellaneous elements */
default: string;
/** Invalid syntax/errors */
invalid: string;
};
};
/**
* Creates a Shiki theme registration from semantic color categories.
*
* Maps our semantic color categories (comment, keyword, function, etc.) to Shiki's TextMate scopes.
* TextMate scopes are hierarchical selectors (e.g., "keyword.control", "variable.parameter") that
* Shiki uses to identify different syntax elements. This abstraction allows us to define themes
* using semantic categories rather than low-level scope patterns.
*/
function createTheme(options: {
name: string;
type: 'light' | 'dark';
bg: string;
fg: string;
colors: ColorCategories;
}): ThemeRegistration {
const { name, type, bg, fg, colors } = options;
return {
name,
type,
bg,
fg,
settings: [
// Comments
{
scope: ['comment'],
settings: {
foreground: colors.basic.comment,
},
},
// Constants: language constants (true, false, null, etc.)
{
scope: ['constant.language'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: numeric
{
scope: [
'constant.numeric',
'variable.other.enummember',
'keyword.operator.plus.exponent',
'keyword.operator.minus.exponent',
'keyword.other.unit',
'constant.sha.git-rebase',
],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: regexp
{
scope: ['constant.regexp'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: character
{
scope: ['constant.character', 'constant.other.option'],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: escape sequences
{
scope: ['constant.character.escape'],
settings: {
foreground: colors.basic.escape,
},
},
// Constants: other
{
scope: [
'constant.other',
'constant.other.color',
'constant.other.symbol',
'constant.other.placeholder',
'constant.other.character-class.regexp',
'constant.character.set.regexp',
'constant.character.character-class.regexp',
'constant.other.character-class.set.regexp',
],
settings: {
foreground: colors.basic.constant,
},
},
// Constants: variable constants
{
scope: ['variable.other.constant'],
settings: {
foreground: colors.basic.constant,
},
},
// Strings
{
scope: [
'string',
'meta.embedded.assembly',
'string.tag',
'string.value',
'string.regexp',
'meta.preprocessor.string',
'support.constant.property-value',
'support.constant.font-name',
'support.constant.media-type',
'support.constant.media',
'constant.other.color.rgb-value',
'constant.other.rgb-value',
'support.constant.color',
'punctuation.definition.group.regexp',
'punctuation.definition.group.assertion.regexp',
'punctuation.definition.character-class.regexp',
'punctuation.character.set.begin.regexp',
'punctuation.character.set.end.regexp',
'keyword.operator.negation.regexp',
'support.other.parenthesis.regexp',
],
settings: {
foreground: colors.basic.string,
},
},
// String expressions (template literals, interpolated)
// Only color the punctuation, not the expression content
{
scope: [
'punctuation.definition.template-expression.begin',
'punctuation.definition.template-expression.end',
'punctuation.section.embedded',
],
settings: {
foreground: colors.variable.parameter,
},
},
// Keywords: control flow
{
scope: [
'keyword.control',
'source.cpp keyword.operator.new',
'keyword.operator.delete',
'keyword.other.using',
'keyword.other.directive.using',
'keyword.other.operator',
'entity.name.operator',
'keyword.operator.new',
'keyword.operator.expression',
'keyword.operator.cast',
'keyword.operator.sizeof',
'keyword.operator.alignof',
'keyword.operator.typeid',
'keyword.operator.alignas',
'keyword.operator.instanceof',
'keyword.operator.logical.python',
'keyword.operator.wordlike',
],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: storage (const, let, var, function, class, etc.)
{
scope: [
'storage',
'storage.type',
'storage.modifier',
'keyword.operator.noexcept',
'storage.type.numeric.go',
'storage.type.byte.go',
'storage.type.boolean.go',
'storage.type.string.go',
'storage.type.uintptr.go',
'storage.type.error.go',
'storage.type.rune.go',
'storage.type.cs',
'storage.type.generic.cs',
'storage.type.modifier.cs',
'storage.type.variable.cs',
'storage.type.annotation.java',
'storage.type.generic.java',
'storage.type.java',
'storage.type.object.array.java',
'storage.type.primitive.array.java',
'storage.type.primitive.java',
'storage.type.token.java',
'storage.type.groovy',
'storage.type.annotation.groovy',
'storage.type.parameters.groovy',
'storage.type.generic.groovy',
'storage.type.object.array.groovy',
'storage.type.primitive.array.groovy',
'storage.type.primitive.groovy',
],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: general
{
scope: ['keyword'],
settings: {
foreground: colors.keyword.default,
},
},
// Keywords: operators (default)
{
scope: ['keyword.operator'],
settings: {
foreground: colors.keyword.operator,
},
},
// Keywords: regexp operators
{
scope: ['keyword.operator.or.regexp', 'keyword.control.anchor.regexp'],
settings: {
foreground: colors.keyword.regexpOperator,
},
},
// Keywords: regexp quantifiers
{
scope: ['keyword.operator.quantifier.regexp'],
settings: {
foreground: colors.keyword.regexpQuantifier,
},
},
// Variable language (this, self, super, etc.)
{
scope: ['variable.language'],
settings: {
foreground: colors.variable.language,
},
},
// Parameters
{
scope: ['variable.parameter'],
settings: {
foreground: colors.variable.parameter,
},
},
// Variables (regular variables, not parameters)
{
scope: [
'variable',
'meta.definition.variable.name',
'support.variable',
'entity.name.variable',
'meta.object-literal.key',
],
settings: {
foreground: colors.variable.default,
},
},
// Functions: invocations (calls) - user-defined functions
{
scope: [
'meta.function-call',
'meta.method-call',
'entity.name.function',
'entity.name.method',
'support.constant.handlebars',
'source.powershell variable.other.member',
'entity.name.operator.custom-literal',
'keyword.other.special-method',
],
settings: {
foreground: colors.function.default,
},
},
// Support functions: built-in/library functions
{
scope: ['support.function', 'support.function.git-rebase'],
settings: {
foreground: colors.function.support,
},
},
// Types: custom type names (DocumentBlockCode, HighlightLine, etc.) - must come first
{
scope: ['entity.name.type', 'entity.name.class', 'entity.other.inherited-class'],
settings: {
foreground: colors.type.custom,
},
},
// Types: primitive (number, boolean, string, etc.) - must come before support.type
{
scope: [
'support.type.primitive',
'storage.type.primitive',
'support.type.builtin',
'entity.name.type.primitive',
'support.type',
],
settings: {
foreground: colors.type.primitive,
},
},
// Types: other (support.class, etc.)
{
scope: [
'support.class',
'entity.name.namespace',
'entity.other.attribute',
'entity.name.scope-resolution',
'meta.type.cast.expr',
'meta.type.new.expr',
'support.constant.math',
'support.constant.json',
'punctuation.separator.namespace.ruby',
],
settings: {
foreground: colors.type.other,
},
},
// Class meta contexts
{
scope: ['meta.class'],
settings: {
foreground: colors.type.classMeta,
},
},
// DOM constants and namespace references (ReactDOM, window.document, etc.)
{
scope: [
'support.constant.dom',
'variable.other.object',
'variable.other.object.property',
'entity.name.class.jsx',
'support.class.builtin',
],
settings: {
foreground: colors.variable.parameter,
},
},
// Entity names: tags, attributes
{
scope: [
'entity.name.tag',
'entity.other.attribute-name',
'support.type.vendored.property-name',
'support.type.property-name',
'source.css variable',
'source.coffee.embedded',
'meta.structure.dictionary.key.python',
],
settings: {
foreground: colors.entity.tag,
},
},
// Attribute IDs (typically styled like functions)
{
scope: ['entity.other.attribute-name.id', 'punctuation.definition.entity'],
settings: {
foreground: colors.entity.attributeId,
},
},
// Punctuation
{
scope: [
'punctuation',
'punctuation.definition.tag',
'punctuation.definition.quote.begin.markdown',
'punctuation.definition.list.begin.markdown',
'punctuation.section.embedded.begin.php',
'punctuation.section.embedded.end.php',
],
settings: {
foreground: colors.other.punctuation,
},
},
// Links: link text
{
scope: [
'markup.underline.link',
'string.other.link',
'punctuation.definition.string.end.markdown',
'punctuation.definition.string.begin.markdown',
],
settings: {
foreground: colors.markup.link,
},
},
// Links: link URLs
{
scope: ['meta.link'],
settings: {
foreground: colors.markup.linkUrl,
},
},
// Preprocessor
{
scope: [
'meta.preprocessor',
'entity.name.function.preprocessor',
'meta.preprocessor.numeric',
],
settings: {
foreground: colors.other.preprocessor,
},
},
// Diff: inserted
{
scope: ['markup.inserted', 'markup.inserted.diff'],
settings: {
foreground: colors.diff.inserted,
},
},
// Diff: deleted
{
scope: ['markup.deleted', 'markup.deleted.diff'],
settings: {
foreground: colors.diff.deleted,
},
},
// Diff: changed
{
scope: ['markup.changed', 'markup.changed.diff', 'meta.diff.header'],
settings: {
foreground: colors.diff.changed,
},
},
// Markup: bold (with bold font style)
{
scope: ['markup.bold', 'punctuation.definition.bold'],
settings: {
foreground: colors.markup.default,
fontStyle: 'bold',
},
},
// Markup: italic (with italic font style)
{
scope: ['markup.italic', 'punctuation.definition.italic'],
settings: {
foreground: colors.markup.default,
fontStyle: 'italic',
},
},
// Markup: inline code
{
scope: ['markup.raw.inline'],
settings: {
foreground: colors.markup.code,
},
},
// Markup: headings, lists, quotes, underline, strikethrough
{
scope: [
'markup.heading',
'markup.heading punctuation.definition.heading',
'entity.name.section',
'header',
'markup.list',
'markup.quote',
'markup.underline',
'markup.strikethrough',
],
settings: {
foreground: colors.markup.default,
},
},
// Separator
{
scope: ['meta.separator'],
settings: {
background: colors.markup.separator,
foreground: fg,
},
},
// Labels
{
scope: ['entity.name.label'],
settings: {
foreground: colors.entity.label,
},
},
// Other
{
scope: [
'meta.embedded',
'source.groovy.embedded',
'string meta.image.inline.markdown',
'variable.legacy.builtin.python',
'storage.modifier.import.java',
'variable.language.wildcard.java',
'storage.modifier.package.java',
],
settings: {
foreground: colors.other.default,
},
},
// Invalid
{
scope: ['invalid'],
settings: {
foreground: colors.other.invalid,
},
},
],
};
}
@@ -8,7 +8,8 @@ async function highlightWithInlines(block: DocumentBlockCode) {
inline,
body: null,
}));
return highlight(block, inlines);
const result = await highlight(block, inlines);
return result.lines;
}
it('should parse plain code', async () => {
@@ -224,7 +225,19 @@ it('should parse code with an inline on a single line', async () => {
{
type: 'shiki',
token: {
content: '"Hello World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -375,7 +388,19 @@ it('should parse code with an inline on a multiple line', async () => {
{
type: 'shiki',
token: {
content: '"Hello World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -631,7 +656,13 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: '.world',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'world',
},
},
{
@@ -645,7 +676,13 @@ it('should support multiple code tokens in an annotation', async () => {
{
type: 'shiki',
token: {
content: ');',
content: ')',
},
},
{
type: 'shiki',
token: {
content: ';',
},
},
],
@@ -699,7 +736,13 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.log',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
},
},
{
@@ -711,7 +754,19 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"Hello"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'Hello',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -734,7 +789,13 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '.log',
content: '.',
},
},
{
type: 'shiki',
token: {
content: 'log',
},
},
{
@@ -746,7 +807,19 @@ it('should handle \\r', async () => {
{
type: 'shiki',
token: {
content: '"World"',
content: '"',
},
},
{
type: 'shiki',
token: {
content: 'World',
},
},
{
type: 'shiki',
token: {
content: '"',
},
},
{
@@ -1,20 +1,39 @@
import type {
DocumentBlockCode,
DocumentBlockCodeLine,
DocumentInlineAnnotation,
import {
CustomizationCodeTheme,
type CustomizationThemedCodeTheme,
type DocumentBlockCode,
type DocumentBlockCodeLine,
type DocumentInlineAnnotation,
} from '@gitbook/api';
import {
type ThemeRegistrationAny,
type ThemedToken,
createCssVariablesTheme,
createSingletonShorthands,
createdBundledHighlighter,
} from 'shiki/core';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { type BundledLanguage, bundledLanguages } from 'shiki/langs';
import { bundledThemes } from 'shiki/themes';
import { nullIfNever } from '@/lib/typescript';
import { customThemes } from './customThemes';
import { plainHighlight } from './plain-highlight';
export const DEFAULT_THEMES: CustomizationThemedCodeTheme = {
light: CustomizationCodeTheme.DefaultLight,
dark: CustomizationCodeTheme.DefaultDark,
};
export type HighlightTheme = {
bg?: string;
fg?: string;
themes: {
light: ThemeRegistrationAny;
dark: ThemeRegistrationAny;
};
lines: HighlightLine[];
};
export type HighlightLine = {
highlighted: boolean;
tokens: HighlightToken[];
@@ -36,12 +55,12 @@ export type RenderedInline = {
const isSafari =
typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const theme = createCssVariablesTheme();
// Merge bundled Shiki themes with our custom themes so both are available to the highlighter
const { getSingletonHighlighter } = createSingletonShorthands(
createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
themes: { ...bundledThemes, ...customThemes },
engine: () => createJavaScriptRegexEngine({ forgiving: true, target: 'ES2018' }),
})
);
@@ -49,12 +68,15 @@ const { getSingletonHighlighter } = createSingletonShorthands(
/**
* Preload the highlighter for a code block.
*/
export async function preloadHighlight(block: DocumentBlockCode) {
export async function preloadHighlight(
block: DocumentBlockCode,
themes: CustomizationThemedCodeTheme = DEFAULT_THEMES
) {
const langName = getBlockLang(block);
if (langName) {
await getSingletonHighlighter({
langs: [langName],
themes: [theme],
themes: [themes.light, themes.dark],
});
}
}
@@ -67,8 +89,9 @@ export async function highlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): Promise<HighlightLine[]> {
): Promise<HighlightTheme> {
const langName = getBlockLang(block);
if (!langName || (isSafari && ['powershell', 'cpp'].includes(langName))) {
@@ -80,13 +103,20 @@ export async function highlight(
return plainHighlight(block, inlines, options);
}
const themes = options?.themes ?? DEFAULT_THEMES;
const code = getPlainCodeBlock(block, undefined, options);
const highlighter = await getSingletonHighlighter({
langs: [langName],
themes: [theme],
themes: [themes.light, themes.dark],
});
const resolvedThemes = {
light: highlighter.getTheme(themes.light),
dark: highlighter.getTheme(themes.dark),
};
let tokenizeMaxLineLength = 400;
// In some cases, people will use unindented code blocks with a single line.
// In this case, we can safely increase the max line length to avoid not highlighting the code.
@@ -94,38 +124,47 @@ export async function highlight(
tokenizeMaxLineLength = 5000;
}
const lines = highlighter.codeToTokensBase(code, {
const result = highlighter.codeToTokens(code, {
lang: langName,
theme,
themes: resolvedThemes,
// Shiki's light-dark() CSS function provides different colors for light/dark modes based on the resolved themes
defaultColor: 'light-dark()',
tokenizeMaxLineLength,
});
const lines = result.tokens;
let currentIndex = 0;
return lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
return {
bg: result.bg,
fg: result.fg,
themes: resolvedThemes,
lines: lines.map((tokens, index) => {
const lineBlock = block.nodes[index];
const result: HighlightToken[] = [];
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
currentIndex += 1; // for the \n
currentIndex += 1; // for the \n
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
});
return {
highlighted: Boolean(lineBlock?.data.highlighted),
tokens: result,
};
}),
};
}
/**
@@ -1,8 +1,9 @@
import type { DocumentBlockCode } from '@gitbook/api';
import type { CustomizationThemedCodeTheme, DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
import { bundledThemesInfo } from 'shiki/themes';
import { customThemes } from './customThemes';
import type { HighlightTheme, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
@@ -12,42 +13,59 @@ export function plainHighlight(
inlines: RenderedInline[],
options?: {
evaluateInlineExpression?: (expr: string) => string;
themes?: CustomizationThemedCodeTheme;
}
): HighlightLine[] {
): HighlightTheme {
const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
};
}
// Resolve theme objects: first check bundled Shiki themes, then custom themes, finally fallback to type-only
// The type-only fallback ensures HighlightTheme always has valid theme objects even when theme names don't match
const themes = {
light: bundledThemesInfo.find((theme) => theme.id === options?.themes?.light) ??
Object.values(customThemes).find((theme) => theme.name === options?.themes?.light) ?? {
type: 'light',
},
dark: bundledThemesInfo.find((theme) => theme.id === options?.themes?.dark) ??
Object.values(customThemes).find((theme) => theme.name === options?.themes?.dark) ?? {
type: 'dark',
},
};
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
return {
themes: themes,
lines: block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
},
],
};
});
};
}
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
});
if (node.type === 'expression') {
return {
type: 'plain',
content: options?.evaluateInlineExpression?.(node.data.expression) ?? '',
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
};
});
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
}),
};
}
@@ -1,6 +1,6 @@
import type { DocumentBlockDrawing } from '@gitbook/api';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { Image } from '../utils';
import type { BlockProps } from './Block';
@@ -8,11 +8,11 @@ import { Caption } from './Caption';
import { imageBlockSizes } from './Images';
export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
const { block, context } = props;
const { document, block, context } = props;
const resolved =
block.data.ref && context.contentContext
? await resolveContentRef(block.data.ref, context.contentContext)
? await resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null;
if (!resolved) {
return null;
@@ -31,7 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
alt="Drawing"
sizes={imageBlockSizes}
zoom
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
</Caption>
);
@@ -60,7 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
) : null
}
@@ -15,6 +15,8 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
const title = getNodeFragmentByType(block, 'expandable-title');
const body = getNodeFragmentByType(block, 'expandable-body');
// @ts-expect-error should be removed once we update the API
const isDefaultExpanded = block.data.defaultExpanded ?? false;
const titleParagraph = title?.nodes[0];
@@ -28,7 +30,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
return (
<Details
id={id}
open={context.mode === 'print'}
open={context.mode === 'print' || isDefaultExpanded}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary
@@ -2,7 +2,7 @@ import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { t } from '@/intl/translate';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { getSpaceLanguage } from '@/intl/server';
import { Button, Link } from '../primitives';
@@ -12,13 +12,17 @@ import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props;
const { document, block, context } = props;
if (!context.contentContext) {
return null;
}
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
const contentRef = await resolveContentRefInDocument(
document,
block.data.ref,
context.contentContext
);
const file = contentRef?.file;
if (!file) {
@@ -1,7 +1,7 @@
import type { DocumentBlockImage, DocumentBlockImages, JSONDocument, Length } from '@gitbook/api';
import { Image, type ImageResponsiveSize } from '@/components/utils';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { type ClassValue, tcls } from '@/lib/tailwind';
import type { BlockProps } from './Block';
@@ -81,12 +81,14 @@ async function ImageBlock(props: {
isEstimatedOffscreen: boolean;
withFrame?: boolean;
}) {
const { block, context, isEstimatedOffscreen, withFrame } = props;
const { document, block, context, isEstimatedOffscreen, withFrame } = props;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(block.data.ref, context.contentContext) : null,
context.contentContext
? resolveContentRefInDocument(document, block.data.ref, context.contentContext)
: null,
block.data.refDark && context.contentContext
? resolveContentRef(block.data.refDark, context.contentContext)
? resolveContentRefInDocument(document, block.data.refDark, context.contentContext)
: null,
]);
@@ -113,7 +115,7 @@ async function ImageBlock(props: {
}
: null,
}}
loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
loading={isEstimatedOffscreen && context.mode !== 'print' ? 'lazy' : 'eager'}
zoom
inlineStyle={{
maxWidth: '100%',
@@ -1,18 +1,28 @@
'use client';
import { clearAdaptiveVisitorClaimsCache } from '@/components/Adaptive/AdaptiveVisitorContextProvider';
import { tString, useLanguage } from '@/intl/client';
import { useAI, useAIChatController, useAIChatState } from '../AI';
import { useSearch } from '../Search';
import { useSetSearchState } from '../Search';
import { Button, type ButtonProps, Input } from '../primitives';
type ClaimInput = {
key: string;
value: string | number | boolean | null;
};
export function InlineActionButton(
props: { action: 'ask' | 'search'; query?: string } & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
props: {
action: 'ask' | 'search' | 'set-claim';
query?: string;
claims?: ClaimInput[];
} & { buttonProps: ButtonProps } // TODO: Type this properly: Pick<api.DocumentInlineButton, 'action' | 'query'> & { buttonProps: ButtonProps }
) {
const { action, query, buttonProps } = props;
const { action, query, claims, buttonProps } = props;
const { assistants } = useAI();
const chatController = useAIChatController();
const chatState = useAIChatState();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
const language = useLanguage();
const handleSubmit = (value: string) => {
@@ -32,11 +42,33 @@ export function InlineActionButton(
}
};
const handleSetClaimAction = () => {
if (!claims?.length) {
return;
}
const mergedClaims: Record<string, string | number | boolean | null> = {};
for (const claim of claims) {
mergedClaims[claim.key] = claim.value;
}
document.cookie = `gitbook-visitor-public=${encodeURIComponent(
JSON.stringify(mergedClaims)
)}; path=/; SameSite=Lax`;
clearAdaptiveVisitorClaimsCache();
window.location.reload();
};
const icon =
action === 'ask' && buttonProps.icon === 'gitbook-assistant' && assistants.length > 0
? assistants[0]?.icon
: buttonProps.icon;
if (action === 'set-claim') {
return <Button {...buttonProps} onClick={handleSetClaimAction} />;
}
if (!query) {
return (
<Input
@@ -1,4 +1,4 @@
import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import { resolveContentRefFallback, resolveContentRefInDocument } from '@/lib/references';
import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button, type ButtonProps } from '../primitives';
@@ -17,11 +17,15 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
};
const ButtonImplementation = () => {
if ('action' in inline.data && 'query' in inline.data.action) {
if ('action' in inline.data) {
const actionData = inline.data.action;
return (
<InlineActionButton
action={inline.data.action.action}
query={inline.data.action.query ?? ''}
action={actionData.action}
query={actionData.query ?? ''}
// @ts-expect-error -- Temporary override until `set-claim` is available in @gitbook/api.
claims={actionData.claims}
buttonProps={buttonProps}
/>
);
@@ -45,13 +49,13 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
export async function InlineLinkButton(
props: InlineProps<api.DocumentInlineButton> & { buttonProps: ButtonProps }
) {
const { inline, context, buttonProps } = props;
const { document, inline, context, buttonProps } = props;
if (!('ref' in inline.data)) return;
const resolved =
context.contentContext && inline.data.ref
? await resolveContentRef(inline.data.ref, context.contentContext)
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null;
const href =
@@ -2,20 +2,22 @@ import type { GitBookBaseContext } from '@/lib/context';
import type { DocumentInlineImage } from '@gitbook/api';
import assertNever from 'assert-never';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Image } from '../utils';
import type { InlineProps } from './Inline';
export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
const { inline, context, ancestorInlines } = props;
const { document, inline, context, ancestorInlines } = props;
const { size = 'original' } = inline.data;
const [src, darkSrc] = await Promise.all([
context.contentContext ? resolveContentRef(inline.data.ref, context.contentContext) : null,
context.contentContext
? resolveContentRefInDocument(document, inline.data.ref, context.contentContext)
: null,
inline.data.refDark && context.contentContext
? resolveContentRef(inline.data.refDark, context.contentContext)
? resolveContentRefInDocument(document, inline.data.refDark, context.contentContext)
: null,
]);
@@ -49,7 +51,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
}
: null,
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline
zoom={!isInLink}
@@ -4,8 +4,8 @@ import { getSpaceLanguage, tString } from '@/intl/server';
import { type TranslationLanguage, languages } from '@/intl/translations';
import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
resolveContentRefInDocument,
} from '@/lib/references';
import { Icon } from '@gitbook/icons';
import { StyledLink } from '../../primitives';
@@ -15,10 +15,10 @@ import { NotFoundRefHoverCard } from '../NotFoundRefHoverCard';
import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
const { inline, document, context, ancestorInlines } = props;
const { document, inline, context, ancestorInlines } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, {
// We don't want to resolve the anchor text here, as it can be very expensive and will block rendering if there is a lot of anchors link.
resolveAnchorText: false,
})
@@ -182,8 +182,16 @@ function getOrderedListItemPrefixContent(input: {
}): string {
const { parent, block } = input;
const start = parent.data.start ?? 1;
const index = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
const value = index + start;
let index: number;
// If the block has a key, use it to find the index. Otherwise, use the object reference.
if (block.key) {
index = parent.nodes.findIndex((node) => node.key === block.key);
} else {
index = parent.nodes.findIndex((node) => node === block);
}
const value = index >= 0 ? index + start : Math.max(start, 1);
switch (input.depth % 3) {
// Use numbers
case 0: {
@@ -1,15 +1,15 @@
import { type DocumentInlineMention, SiteInsightsLinkPosition } from '@gitbook/api';
import { StyledLink } from '@/components/primitives';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import type { InlineProps } from './Inline';
export async function Mention(props: InlineProps<DocumentInlineMention>) {
const { inline, context } = props;
const { document, inline, context } = props;
const resolved = context.contentContext
? await resolveContentRef(inline.data.ref, context.contentContext, {
? await resolveContentRefInDocument(document, inline.data.ref, context.contentContext, {
resolveAnchorText: true,
})
: null;
@@ -42,7 +42,17 @@ export function getOpenAPIContext(args: {
check: <Icon icon="check" />,
lock: <Icon icon="lock" />,
},
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
renderCodeBlock: (codeProps) => (
<PlainCodeBlock
{...codeProps}
themeKey="openapi"
themes={
context && 'customization' in context
? context.customization.styling.codeTheme.openapi
: undefined
}
/>
),
renderDocument: (documentProps) => (
<DocumentView
document={documentProps.document as JSONDocument}
@@ -273,9 +273,9 @@ body {
.scalar-activate-button {
@apply flex gap-2 items-center;
@apply bg-primary-original text-contrast-primary-original hover:bg-primary-solid-hover hover:text-contrast-primary-solid-hover contrast-more:ring-1 rounded-md straight-corners:rounded-none circular-corners:rounded-full circular-corners:px-3 place-self-start;
@apply shadow-sm shadow-primary dark:shadow-primary-1 hover:shadow-md active:shadow-none;
@apply depth-subtle:shadow-xs hover:depth-subtle:shadow-md focus-visible:depth-subtle:shadow-md active:depth-subtle:shadow-xs;
@apply contrast-more:ring-tint-12 contrast-more:hover:ring-2 contrast-more:hover:ring-tint-12;
@apply hover:scale-105 active:scale-100 transition-all;
@apply hover:depth-subtle:-translate-y-px focus-visible:depth-subtle:-translate-y-px active:depth-subtle:translate-y-0 transition-all;
@apply grow-0 shrink-0 truncate;
@apply text-[13px] px-2 py-0.5 font-mono font-medium [word-spacing:-2px];
}
@@ -527,7 +527,7 @@
.openapi-panel,
.openapi-codesample,
.openapi-response-examples {
@apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
@apply border shrink min-h-32 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base border-tint-subtle depth-subtle:shadow-sm;
}
.openapi-response-examples-panel {
@@ -541,7 +541,9 @@
.openapi-panel pre,
.openapi-codesample pre,
.openapi-response-examples pre {
@apply bg-transparent border-none rounded-none shrink shadow-none;
@apply border-none rounded-none shrink shadow-none;
/* Override the code block's background to transparent so it matches with the header and footer */
@apply bg-transparent!
}
.openapi-panel-heading {
@@ -845,6 +847,14 @@ body:has(.openapi-select-popover) {
@apply -mx-3 px-3 py-2.5 transition-all flex flex-col ring-tint-subtle;
}
.openapi-schemas-disclosure .openapi-schema-properties > .openapi-disclosure > .openapi-disclosure-trigger {
@apply px-3;
}
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-disclosure > .openapi-disclosure-trigger {
@apply px-6;
}
.openapi-disclosure:not(
.openapi-disclosure-group .openapi-disclosure,
.openapi-schema-alternatives .openapi-disclosure,
@@ -873,7 +883,7 @@ body:has(.openapi-select-popover) {
}
.openapi-disclosure-trigger-label {
@apply absolute right-3 mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
@apply absolute right-3 font-sans! mr-px px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none circular-corners:rounded-xl flex flex-row gap-1 items-center text-xs;
}
.openapi-disclosure-trigger-label span {
@@ -1,12 +1,15 @@
import type { DocumentBlockReusableContent } from '@gitbook/api';
import { getDataOrNull } from '@/lib/data';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import type { BlockProps } from './Block';
import { UnwrappedBlocks } from './Blocks';
// TODO-DEREF: Remove this once we have rolled out the new reusable content deref in the API.
export async function ReusableContent(props: BlockProps<DocumentBlockReusableContent>) {
const { block, context, ancestorBlocks } = props;
const { document, block, context, ancestorBlocks } = props;
console.error('Reusable content should be handled at the API level, this should not be called');
if (!context.contentContext) {
throw new Error('Expected a content context to render a reusable content block');
@@ -16,7 +19,7 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
: context.contentContext.dataFetcher;
const resolved = await resolveContentRef(block.data.ref, {
const resolved = await resolveContentRefInDocument(document, block.data.ref, {
...context.contentContext,
dataFetcher,
});
@@ -30,7 +33,7 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
return null;
}
const document = await getDataOrNull(
const reusableContentDocument = await getDataOrNull(
dataFetcher.getRevisionReusableContentDocument({
spaceId: reusableContent.context.space.id,
revisionId: reusableContent.context.revisionId,
@@ -38,14 +41,14 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
})
);
if (!document) {
if (!reusableContentDocument) {
return null;
}
return (
<UnwrappedBlocks
nodes={document.nodes}
document={document}
nodes={reusableContentDocument.nodes}
document={reusableContentDocument}
ancestorBlocks={[...ancestorBlocks, block]}
context={{
...context,
@@ -1,6 +1,6 @@
import { LinkBox, LinkOverlay } from '@/components/primitives';
import { Image } from '@/components/utils';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
import { type ResolvedContentRef, resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import {
CardsImageObjectFit,
@@ -18,7 +18,7 @@ export async function RecordCard(
record: TableRecordKV;
}
) {
const { view, record, context, block, isOffscreen } = props;
const { view, record, context, block, isOffscreen, document } = props;
const { dark, light } = getRecordCardCovers(record[1], view);
const targetRef = view.targetDefinition
@@ -27,13 +27,13 @@ export async function RecordCard(
const [lightCover, darkCover, target] = await Promise.all([
light.contentRef && context.contentContext
? resolveContentRef(light.contentRef, context.contentContext)
? resolveContentRefInDocument(document, light.contentRef, context.contentContext)
: null,
dark.contentRef && context.contentContext
? resolveContentRef(dark.contentRef, context.contentContext)
? resolveContentRefInDocument(document, dark.contentRef, context.contentContext)
: null,
targetRef && context.contentContext
? resolveContentRef(targetRef, context.contentContext)
? resolveContentRefInDocument(document, targetRef, context.contentContext)
: null,
]);
@@ -120,7 +120,7 @@ export async function RecordCard(
: ['h-auto', 'aspect-video'],
objectFits
)}
loading={isOffscreen ? 'lazy' : 'eager'}
loading={isOffscreen && context.mode !== 'print' ? 'lazy' : 'eager'}
/>
) : null}
<div
@@ -12,7 +12,7 @@ import { StyledLink } from '@/components/primitives';
import { Image } from '@/components/utils';
import { getNodeFragmentByName } from '@/lib/document';
import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references';
import { resolveContentRefInDocument } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { filterOutNullable } from '@/lib/typescript';
import type { BlockProps } from '../Block';
@@ -176,7 +176,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
const files = await Promise.all(
value.map((fileId) =>
context.contentContext
? resolveContentRef(
? resolveContentRefInDocument(
document,
{
kind: 'file',
file: fileId,
@@ -229,7 +230,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
},
},
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
) : (
<FileIcon
@@ -250,7 +251,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const resolved =
value && context.contentContext
? await resolveContentRef(value, context.contentContext, {
? await resolveContentRefInDocument(document, value, context.contentContext, {
resolveAnchorText: true,
iconStyle: ['mr-2', 'text-tint-subtle'],
})
@@ -293,7 +294,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
user: userId,
};
const resolved = context.contentContext
? await resolveContentRef(contentRef, context.contentContext)
? await resolveContentRefInDocument(
document,
contentRef,
context.contentContext
)
: null;
if (!resolved) {
return null;
@@ -366,7 +371,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
}
const image = context.contentContext
? await resolveContentRef(
? await resolveContentRefInDocument(
document,
'ref' in value ? value.ref : value,
context.contentContext
)
@@ -404,7 +410,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
size: image.file?.dimensions,
},
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
{image.text}
</StyledLink>
@@ -44,8 +44,8 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
>
<div
className={tcls(
// Date is only sticky on larger screens when we use flex-row layout
'h-fit w-40 min-w-40 shrink-0 md:sticky md:top-[calc(var(--toc-top-offset)+8px)]!'
// Date is only sticky on larger screens when we use flex-row layout, with 0px fallback to prevent flicker and flaky tests before JS sets the variable
'h-fit w-40 min-w-40 shrink-0 md:sticky md:top-[calc(var(--toc-top-offset,0px)+8px)]!'
)}
>
<time
@@ -60,8 +60,7 @@ export function Update(props: BlockProps<DocumentBlockUpdate>) {
{...contextProps}
nodes={block.nodes}
ancestorBlocks={[...ancestorBlocks, block]}
// Remove padding-top from headings when they're the first child (similar to column-first-of-type pattern)
style="[&>*:first-child]:!pt-0 flex-1 space-y-4"
style="[&>*:first-child]:!pt-0 flex flex-1 flex-col [&>*+*]:mt-5"
/>
</div>
);
@@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind';
import { ThemeToggler } from '../ThemeToggler';
import { CONTAINER_STYLE } from '../layout';
import { FooterLinksGroup } from './FooterLinksGroup';
import { SocialLink } from './SocialLink';
const FOOTER_COLUMNS = 4;
@@ -20,8 +21,10 @@ export function Footer(props: { context: GitBookSiteContext }) {
const hasGroups = groupCount > 0;
const hasCopyright = customization.footer.copyright;
const hasThemeToggle = customization.themes.toggeable;
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
const mobileOnly = !hasLogo && !hasGroups && !hasCopyright && hasThemeToggle;
const mobileOnly =
!hasLogo && !hasGroups && !hasCopyright && !socialLinks.length && hasThemeToggle;
return (
<footer
@@ -137,6 +140,21 @@ export function Footer(props: { context: GitBookSiteContext }) {
) : null
}
{
// Social Links
socialLinks.length > 0 ? (
<div className="col-span-full flex w-full grow items-center justify-center gap-2">
{socialLinks.map((account) => (
<SocialLink
key={`${account.platform}-${account.handle}`}
account={account}
target={customization.externalLinks.target}
/>
))}
</div>
) : null
}
{
// Legal
customization.footer.copyright ? (
@@ -0,0 +1,104 @@
import {
type SiteExternalLinksTarget,
type SiteSocialAccount,
SiteSocialAccountPlatform,
} from '@gitbook/api';
import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives';
type SocialPlatformData = {
label: string;
icon: IconName;
/** The href to the social platform. `$handle` will be replaced with the account handle. */
href: string;
};
const SOCIAL_PLATFORMS: Record<SiteSocialAccountPlatform, SocialPlatformData> = {
[SiteSocialAccountPlatform.Twitter]: {
label: 'X/Twitter',
icon: 'x-twitter',
href: 'https://x.com/$handle',
},
[SiteSocialAccountPlatform.Instagram]: {
label: 'Instagram',
icon: 'instagram',
href: 'https://instagram.com/$handle',
},
[SiteSocialAccountPlatform.Facebook]: {
label: 'Facebook',
icon: 'facebook',
href: 'https://facebook.com/$handle',
},
[SiteSocialAccountPlatform.Linkedin]: {
label: 'LinkedIn',
icon: 'linkedin',
href: 'https://linkedin.com/$handle',
},
[SiteSocialAccountPlatform.Github]: {
label: 'GitHub',
icon: 'github',
href: 'https://github.com/$handle',
},
[SiteSocialAccountPlatform.Discord]: {
label: 'Discord',
icon: 'discord',
href: 'https://discord.com/$handle',
},
[SiteSocialAccountPlatform.Slack]: {
label: 'Slack',
icon: 'slack',
href: 'https://join.slack.com/t/$handle',
},
[SiteSocialAccountPlatform.Youtube]: {
label: 'YouTube',
icon: 'youtube',
href: 'https://youtube.com/@$handle',
},
[SiteSocialAccountPlatform.Tiktok]: {
label: 'TikTok',
icon: 'tiktok',
href: 'https://tiktok.com/@$handle',
},
[SiteSocialAccountPlatform.Reddit]: {
label: 'Reddit',
icon: 'reddit',
href: 'https://reddit.com/@$handle',
},
[SiteSocialAccountPlatform.Bluesky]: {
label: 'Bluesky',
icon: 'bluesky',
href: 'https://bsky.app/profile/$handle',
},
[SiteSocialAccountPlatform.Mastodon]: {
label: 'Mastodon',
icon: 'mastodon',
href: 'https://mastodon.social/@$handle',
},
[SiteSocialAccountPlatform.Threads]: {
label: 'Threads',
icon: 'threads',
href: 'https://threads.net/@$handle',
},
[SiteSocialAccountPlatform.Medium]: {
label: 'Medium',
icon: 'medium',
href: 'https://medium.com/@$handle',
},
};
export function SocialLink(props: { account: SiteSocialAccount; target: SiteExternalLinksTarget }) {
const { account, target } = props;
const platform = SOCIAL_PLATFORMS[account.platform];
return (
<Button
iconOnly
label={platform.label}
href={platform.href.replace('$handle', account.handle)}
icon={platform.icon}
variant="blank"
size="large"
target={target}
/>
);
}
@@ -115,13 +115,6 @@ function HeaderItemButton(
href={href}
variant={variant}
size="medium"
className={tcls(
{
'button-primary':
'theme-bold:bg-header-link theme-bold:text-header-background theme-bold:shadow-none hover:theme-bold:bg-header-link hover:theme-bold:text-header-background hover:theme-bold:shadow-none',
'button-secondary': '',
}[linkStyle]
)}
insights={{
type: 'link_click',
link: {
@@ -31,3 +31,12 @@ export function isCookiesTrackingDisabled() {
return undefined;
}
/**
* Return true if cookies are accepted.
* Return false if cookies are rejected or unknown.
*/
export function hasApprovedCookies() {
const state = getBrowserCookie(GRANTED_COOKIE);
return state === 'yes';
}
@@ -1,14 +1,14 @@
'use client';
import * as React from 'react';
import * as zustand from 'zustand';
import { hasApprovedCookies, setCookiesTracking } from '@/components/Insights';
import type {
GitBookGlobal,
GitBookIntegrationEvent,
GitBookIntegrationEventCallback,
GitBookIntegrationTool,
} from '@gitbook/browser-types';
import * as React from 'react';
import * as zustand from 'zustand';
import type { Assistant } from '../AI';
const events = new Map<GitBookIntegrationEvent, GitBookIntegrationEventCallback[]>();
@@ -26,6 +26,26 @@ export const integrationsAssistantTools = zustand.createStore<{
export const integrationAssistants = zustand.createStore<Array<Assistant>>(() => []);
// Store to track when integrations have been loaded
export const integrationsStore = zustand.createStore<{
loaded: boolean;
}>(() => {
return {
loaded: false,
};
});
type CustomCookieBannerStore = {
hasCustomCookieBanner: boolean;
};
// Store for custom cookie banner registration
export const customCookieBannerStore = zustand.createStore<CustomCookieBannerStore>(() => {
return {
hasCustomCookieBanner: false,
};
});
if (typeof window !== 'undefined') {
const gitbookGlobal: GitBookGlobal = {
addEventListener: (event, callback) => {
@@ -65,6 +85,24 @@ if (typeof window !== 'undefined') {
integrationAssistants.setState((state) => state.filter((a) => a.id !== id), true);
};
},
registerCookieBanner: (handler) => {
customCookieBannerStore.setState((state) => ({
...state,
hasCustomCookieBanner: true,
}));
handler({
onApprove: () => {
setCookiesTracking(true);
window.location.reload();
},
onReject: () => {
setCookiesTracking(false);
window.location.reload();
},
});
},
hasApprovedCookies: hasApprovedCookies,
};
window.GitBook = gitbookGlobal;
}
@@ -76,12 +114,30 @@ export function useIntegrationAssistants(): Array<Assistant> {
return zustand.useStore(integrationAssistants);
}
/**
* Hook to check if integrations have been loaded.
*/
export function useIntegrationsLoaded(): boolean {
return zustand.useStore(integrationsStore, (state) => state.loaded);
}
/**
* Hook to check if a custom cookie banner is registered.
*/
export function useCustomCookieBanner(): CustomCookieBannerStore {
return zustand.useStore(customCookieBannerStore);
}
/**
* Dispatch the `load` event to all integrations.
*/
export function LoadIntegrations() {
React.useEffect(() => {
// Only dispatch 'load' event when there are scripts to load
dispatchGitBookIntegrationEvent('load');
integrationsStore.setState({ loaded: true });
}, []);
return null;
}
@@ -89,6 +145,6 @@ export function LoadIntegrations() {
/**
* Client function to dispatch a GitBook event.
*/
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: any[]) {
function dispatchGitBookIntegrationEvent(type: GitBookIntegrationEvent, ...args: unknown[]) {
events.get(type)?.forEach((handler) => handler(...args));
}
@@ -0,0 +1,141 @@
'use client';
import { tString } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import assertNever from 'assert-never';
import * as React from 'react';
export function ImagesLoadingStatus(props: {
language: TranslationLanguage;
}) {
const { language } = props;
const state = useImagesLoadingState();
return (
<p className="text-right text-slate-500 text-xs">
{(() => {
switch (state.status) {
case 'pending':
return null;
case 'loading':
return tString(
language,
'pdf_images_loading',
`${state.loadedImages}`,
`${state.totalImages}`
);
case 'ready':
return tString(language, 'pdf_images_loaded');
default:
assertNever(state);
}
})()}
</p>
);
}
/**
* Keep track of images loading state.
*/
function useImagesLoadingState() {
const [totalImages, setTotalImages] = React.useState<number | null>(null);
const [loadedImages, setLoadedImages] = React.useState(0);
const attachedImages = React.useRef(new WeakSet<HTMLImageElement>());
const rafRef = React.useRef<number>(0);
const calculateImageStatus = React.useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
const images = Array.from(document.images);
setTotalImages(images.length);
setLoadedImages(images.filter((image) => image.complete).length);
});
}, []);
React.useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
const attachListeners = React.useCallback(
(images: HTMLImageElement[]) => {
images.forEach((image) => {
if (image.complete || attachedImages.current.has(image)) {
return;
}
if (image.loading !== 'eager') {
console.warn('An image is not in "eager" mode', image);
}
attachedImages.current.add(image);
image.addEventListener('load', calculateImageStatus);
image.addEventListener('error', calculateImageStatus);
});
},
[calculateImageStatus]
);
React.useEffect(() => {
calculateImageStatus();
attachListeners(Array.from(document.images));
let raf: number;
const observer = new MutationObserver((mutationsList) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const newImages: HTMLImageElement[] = [];
let shouldRecalculate = false;
for (const mutation of mutationsList) {
if (mutation.type !== 'childList') {
continue;
}
if (mutation.removedNodes.length > 0) {
// Images may have been removed; recalculate counts.
shouldRecalculate = true;
}
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLImageElement) {
newImages.push(node);
shouldRecalculate = true;
} else if (node instanceof HTMLElement) {
const nestedImages = Array.from(
node.getElementsByTagName('img')
) as HTMLImageElement[];
if (nestedImages.length > 0) {
newImages.push(...nestedImages);
shouldRecalculate = true;
}
}
});
}
if (newImages.length > 0) {
attachListeners(newImages);
}
if (shouldRecalculate) {
calculateImageStatus();
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
Array.from(document.images).forEach((image) => {
image.removeEventListener('load', calculateImageStatus);
image.removeEventListener('error', calculateImageStatus);
});
};
}, [attachListeners, calculateImageStatus]);
if (totalImages === null) {
return { status: 'pending' } as const;
}
return {
status: totalImages === loadedImages ? 'ready' : 'loading',
totalImages,
loadedImages,
} as const;
}
+11 -41
View File
@@ -24,11 +24,12 @@ import { tcls } from '@/lib/tailwind';
import { defaultCustomization } from '@/lib/utils';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { PDFPrintControls } from './PDFPrintControls';
import { PageControlButtons } from './PageControlButtons';
import { PrintButton } from './PrintButton';
import './pdf.css';
import { sanitizeGitBookAppURL } from '@/lib/app';
import { getPageDocument } from '@/lib/data';
import { ImagesLoadingStatus } from './ImagesLoadingStatus';
const DEFAULT_LIMIT = 100;
@@ -118,28 +119,9 @@ export async function PDFPage(props: {
</div>
) : null}
<div className={tcls('fixed', 'right-12', 'top-12', 'print:hidden', 'z-50')}>
<PrintButton
title={tString(language, 'pdf_print')}
className={tcls(
'flex',
'flex-row',
'items-center',
'justify-center',
'text-sm',
'text-tint',
'hover:text-primary',
'p-4',
'rounded-full',
'bg-white',
'shadow-xs',
'hover:shadow-md',
'border-slate-300',
'border'
)}
>
<Icon icon="print" className={tcls('size-6')} />
</PrintButton>
<div className="fixed top-12 right-12 z-50 flex flex-col items-end gap-2 print:hidden">
<PDFPrintControls language={language} />
<ImagesLoadingStatus language={language} />
</div>
<PageControlButtons
@@ -187,10 +169,8 @@ async function PDFSpaceIntro(props: {
return (
<PrintPage isFirst>
<div className={tcls('flex', 'items-center', 'justify-center', 'py-12')}>
<h1 className={tcls('text-6xl', 'font-bold')}>
{customization.title ?? space.title}
</h1>
<div className="flex items-center justify-center py-12">
<h1 className="font-bold text-6xl">{customization.title ?? space.title}</h1>
</div>
</PrintPage>
);
@@ -201,18 +181,8 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
return (
<PrintPage id={getPagePDFContainerId(page)}>
<div
className={tcls(
'break-before-page',
'mt-10',
'print:mt-0',
'flex',
'items-center',
'justify-center',
'py-12'
)}
>
<h1 className={tcls('text-5xl', 'font-bold')}>{page.title}</h1>
<div className="mt-10 flex break-before-page items-center justify-center py-12 print:mt-0">
<h1 className="font-bold text-5xl">{page.title}</h1>
</div>
</PrintPage>
);
@@ -227,9 +197,9 @@ async function PDFPageDocument(props: {
return (
<PrintPage id={getPagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
<h1 className="font-bold text-4xl">{page.title}</h1>
{page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
<p className="mt-2 mb-3 decoration-primary/6">{page.description}</p>
) : null}
{document ? (
@@ -0,0 +1,18 @@
import { tString } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import { Icon } from '@gitbook/icons';
import { PrintButton } from './PrintButton';
export function PDFPrintControls(props: { language: TranslationLanguage }) {
const { language } = props;
return (
<PrintButton
title={tString(language, 'pdf_print')}
className="flex items-center justify-center rounded-full border border-slate-300 bg-white p-4 text-sm text-tint shadow-xs hover:text-primary hover:shadow-md"
>
<Icon icon="print" className="size-6" />
</PrintButton>
);
}
@@ -1,19 +1,7 @@
'use client';
import * as React from 'react';
import type { ComponentPropsWithRef } from 'react';
import type { PolymorphicComponentProp } from '@/components/utils/types';
export function PrintButton(props: PolymorphicComponentProp<'button'>) {
const { className, children, ...rest } = props;
const onClick = React.useCallback(() => {
window.print();
}, []);
return (
<button {...rest} data-testid="print-button" onClick={onClick} className={className}>
{children}
</button>
);
export function PrintButton(props: Omit<ComponentPropsWithRef<'button'>, 'onClick'>) {
return <button {...props} data-testid="print-button" onClick={() => window.print()} />;
}
@@ -7,6 +7,7 @@ import { hasFullWidthBlock, hasMoreThan, hasTopLevelBlock, isNodeEmpty } from '@
import type { AncestorRevisionPage } from '@/lib/pages';
import { tcls } from '@/lib/tailwind';
import { DocumentView, DocumentViewSkeleton } from '../DocumentView';
import { InlineActionButton } from '../DocumentView/InlineActionButton';
import { TrackPageViewEvent } from '../Insights';
import { PageFeedbackForm } from '../PageFeedback';
import { CurrentPageProvider } from '../hooks/useCurrentPage';
@@ -65,6 +66,8 @@ export function PageBody(props: {
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
).length > 0;
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
return (
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
<main
@@ -76,12 +79,25 @@ export function PageBody(props: {
'@container',
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
siteWidthWide ? 'site-width-wide' : 'site-width-default',
page.layout.tableOfContents && hasVisibleTOCItems
? 'page-has-toc'
: 'page-no-toc'
pageHasToc ? 'page-has-toc' : 'page-no-toc'
)}
>
<PreservePageLayout siteWidthWide={siteWidthWide} />
<div>
Special button:
<InlineActionButton
action="set-claim"
claims={[
{
key: 'format',
value: ['docker', 'helm', 'generic', 'maven'][
Math.floor(Math.random() * 4)
] as string,
},
]}
buttonProps={{ label: 'Ask' }}
/>
</div>
<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}
@@ -11,9 +11,10 @@ import * as React from 'react';
* 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 }) {
const { siteWidthWide } = 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
@@ -28,7 +29,15 @@ export function PreservePageLayout(props: { siteWidthWide: boolean }) {
} else {
header.classList.remove('site-width-wide');
}
}, [siteWidthWide]);
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, pageHasToc]);
return null;
}
@@ -34,6 +34,7 @@ import type { GitBookAnyContext } from '@/lib/context';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@/lib/env';
import { defaultCustomization } from '@/lib/utils';
import { AnnouncementDismissedScript } from '../Announcement';
import { OperatingSystemClassScript } from './OperatingSystemClassScript';
function preloadFont(fontData: FontData) {
if (fontData.type === 'custom') {
@@ -118,6 +119,8 @@ export async function CustomizationRootLayout(props: {
<link rel="privacy-policy" href={customization.privacyPolicy.url} />
) : null}
<OperatingSystemClassScript />
{/* Inject custom font @font-face rules */}
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
{monospaceFontData.type === 'custom' ? (
@@ -135,7 +138,7 @@ export async function CustomizationRootLayout(props: {
undefined
}
>{`
:root {
:root, .light, .dark [data-color-scheme$="light"], .dark [data-follow-color-scheme="true"]:has([data-color-scheme$="light"]) {
${generateColorVariable('primary', customization.styling.primaryColor.light)}
${generateColorVariable('tint', tintColor ? tintColor.light : DEFAULT_TINT_COLOR, { mix: mixColor && { color: mixColor.color.light, ratio: mixColor.ratio.light } })}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR)}
@@ -162,7 +165,7 @@ export async function CustomizationRootLayout(props: {
${generateColorVariable('success', successColor.light)}
}
.dark {
.dark, :root:not(.dark) [data-color-scheme^="dark"], :root:not(.dark) [data-follow-color-scheme="true"]:has([data-color-scheme^="dark"]) {
${generateColorVariable('primary', customization.styling.primaryColor.dark, { darkMode: true })}
${generateColorVariable('tint', tintColor ? tintColor.dark : DEFAULT_TINT_COLOR, { darkMode: true, mix: mixColor && { color: mixColor?.color.dark, ratio: mixColor.ratio.dark } })}
${generateColorVariable('neutral', DEFAULT_TINT_COLOR, { darkMode: true })}
@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
// This function has to be pure because we do a `.toString()` on it.
function setOperatingSystemClass() {
if (typeof navigator === 'undefined') return;
const OS_MAC_CLASS = 'os-mac';
const platform = navigator.platform.toLowerCase();
const isMac = platform.includes('mac');
const root = document.documentElement;
if (isMac) {
root.classList.add(OS_MAC_CLASS);
} else {
root.classList.remove(OS_MAC_CLASS);
}
}
/**
* Inject a script to detect the OS and apply a class to the <html> element as early as possible.
* Bypasses react state to prevent flickering.
*/
export function OperatingSystemClassScript() {
React.useEffect(() => {
setOperatingSystemClass();
}, []);
return (
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${setOperatingSystemClass.toString()})()`,
}}
/>
);
}
@@ -296,88 +296,36 @@ html.announcement-hidden #announcement-banner {
@apply hidden;
}
/* Shiki theme */
:root {
--shiki-color-text: rgb(var(--tint-11));
--shiki-token-punctuation: rgb(var(--tint-11));
--shiki-token-comment: rgb(var(--neutral-9));
--shiki-token-link: rgb(var(--primary-10));
--shiki-token-constant: rgb(var(--warning-10));
--shiki-token-string: rgb(var(--warning-10));
--shiki-token-string-expression: rgb(var(--success-10));
--shiki-token-keyword: rgb(var(--danger-10));
--shiki-token-parameter: rgb(var(--warning-10));
--shiki-token-function: rgb(var(--primary-10));
--shiki-token-inserted: rgb(var(--success-10));
--shiki-token-deleted: rgb(var(--danger-10));
--shiki-token-changed: rgb(var(--tint-12));
}
@media (prefers-contrast: more) {
:root {
--shiki-color-text: rgb(var(--tint-12));
--shiki-token-punctuation: rgb(var(--tint-12));
--shiki-token-comment: rgb(var(--neutral-11));
--shiki-token-link: rgb(var(--primary-11));
--shiki-token-constant: rgb(var(--warning-11));
--shiki-token-string: rgb(var(--warning-11));
--shiki-token-string-expression: rgb(var(--success-11));
--shiki-token-keyword: rgb(var(--danger-11));
--shiki-token-parameter: rgb(var(--warning-11));
--shiki-token-function: rgb(var(--primary-11));
--shiki-token-inserted: rgb(var(--success-11));
--shiki-token-deleted: rgb(var(--danger-11));
--shiki-token-changed: rgb(var(--tint-12));
}
}
html.dark {
/* Override select colors to have more contrast */
--shiki-token-comment: rgb(var(--neutral-9));
--shiki-token-constant: rgb(var(--warning-11));
--shiki-token-string: rgb(var(--warning-11));
--shiki-token-string-expression: rgb(var(--success-11));
--shiki-token-keyword: rgb(var(--danger-11));
--shiki-token-parameter: rgb(var(--warning-11));
--shiki-token-function: rgb(var(--primary-11));
}
.code-monochrome {
--shiki-token-constant: rgb(var(--tint-11));
--shiki-token-string: rgb(var(--tint-12));
--shiki-token-string-expression: rgb(var(--tint-12));
--shiki-token-keyword: rgb(var(--primary-10));
--shiki-token-parameter: rgb(var(--tint-9));
--shiki-token-function: rgb(var(--primary-9));
}
html.dark.code-monochrome {
--shiki-token-constant: rgb(var(--tint-11));
--shiki-token-string: rgb(var(--tint-12));
--shiki-token-string-expression: rgb(var(--tint-12));
--shiki-token-keyword: rgb(var(--primary-11));
--shiki-token-parameter: rgb(var(--tint-10));
--shiki-token-function: rgb(var(--primary-10));
}
html.font-Lato {
/* Lato's default ligatures impact readability, so we turn them off */
font-variant-ligatures: no-common-ligatures;
}
/* Code blocks */
/* Shiki themes can define font styling (style, weight, decoration) via CSS variables.
* These variables are prefixed with --shiki-{mode}-{property} and allow themes to customize
* typography beyond just colors. We apply them here so code blocks respect theme font styling. */
.shiki, .shiki span {
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
html.dark .shiki,
html.dark .shiki span {
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
.highlight-line {
@apply grid grid-cols-subgrid col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-1 rounded-sm;
@apply text-tint-strong grid grid-cols-subgrid col-span-2 bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base relative hover:invert-5 hover:z-1 rounded-sm;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
@apply bg-tint-active;
/* Use `invert-` to get a dynamic color that contrasts with the background, regardless of the codeblock's theme. */
@apply bg-tint-base invert-10 hover:invert-15;
@apply first:rounded-t-md first:*:mt-1;
@apply last:rounded-b-md last:*:mb-1;
@apply rounded-none;
@@ -397,11 +345,11 @@ html.font-Lato {
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-linear-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
@apply before:text-tint before:content-[counter(line)];
@apply text-sm text-tint text-right pr-3.5 bg-tint-subtle theme-muted:bg-tint-base theme-bold-tint:bg-tint-base rounded-l pl-2 sticky left-[-3px] mask-r-from-80%;
@apply before:content-[counter(line)] not-contrast-more:before:opacity-6;
.highlight-line.highlighted > & {
@apply before:text-tint bg-linear-to-r from-80% from-tint-active to-transparent;
.highlighted & {
@apply bg-tint-base
}
}
@@ -14,7 +14,7 @@ import { useTrackEvent } from '../Insights';
import { Button, Link } from '../primitives';
import { useSearchAskContext } from './SearchAskContext';
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
import { useSearch, useSearchLink } from './useSearch';
import { useSearchLink, useSetSearchState } from './useSearch';
export type SearchAskState =
| {
@@ -130,7 +130,7 @@ function TransitionAnswerBody(props: {
}, [answer]);
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return display ? (
<div className={tcls('flex w-full flex-col gap-4')}>
@@ -9,14 +9,14 @@ import { useAI } from '../AI';
import { AIChatButton } from '../AIChat';
import { useTrackEvent } from '../Insights';
import { useIsMobile } from '../hooks/useIsMobile';
import { Popover } from '../primitives';
import { Popover, useBodyLoaded } from '../primitives';
import { SearchAskAnswer } from './SearchAskAnswer';
import { useSearchAskState } from './SearchAskContext';
import { SearchAskProvider } from './SearchAskContext';
import { SearchInput } from './SearchInput';
import { SearchResults, type SearchResultsRef } from './SearchResults';
import { SearchScopeControl } from './SearchScopeControl';
import { useSearch } from './useSearch';
import { useSearchState, useSetSearchState } from './useSearch';
import { useSearchResults } from './useSearchResults';
import { useSearchResultsCursor } from './useSearchResultsCursor';
@@ -60,12 +60,14 @@ export function SearchContainer({
}: SearchContainerProps) {
const { assistants, config } = useAI();
const [state, setSearchState] = useSearch();
const state = useSearchState();
const setSearchState = useSetSearchState();
const searchAsk = useSearchAskState();
const router = useRouter();
const trackEvent = useTrackEvent();
const resultsRef = useRef<SearchResultsRef>(null);
const searchInputRef = useRef<HTMLDivElement>(null);
const isLoaded = useBodyLoaded();
const isMobile = useIsMobile();
@@ -79,11 +81,13 @@ export function SearchContainer({
if (assistants.length === 0) return;
if (state?.ask === undefined || state?.ask === null) return;
initialRef.current = true;
// For simplicity we're only triggering the first assistant
assistants[0]?.open(state?.ask ?? undefined);
}, [state?.ask, assistants.length, assistants[0]?.open]);
// Because this is in the layout, we need to await for the body to be loaded.
if (isLoaded) {
assistants[0]?.open(state.ask ?? undefined);
initialRef.current = true;
}
}, [state?.ask, assistants.length, assistants[0]?.open, isLoaded]);
const onClose = React.useCallback(
async (to?: string) => {
@@ -68,9 +68,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
className="size-text-lg shrink-0 text-tint theme-bold:text-header-link/8"
/>
}
onChange={(event) => {
onChange(event.target.value);
}}
onValueChange={onChange}
value={value}
maxLength={512}
autoComplete="off"
@@ -3,7 +3,7 @@
import { t, tString, useLanguage } from '@/intl/client';
import type { SiteSection } from '@gitbook/api';
import { Button, DropdownMenu, DropdownMenuItem, ToggleChevron } from '../primitives';
import { useSearch } from './useSearch';
import { useSearchState, useSetSearchState } from './useSearch';
interface SearchScopeControlProps {
spaceTitle: string;
@@ -20,8 +20,7 @@ interface SearchScopeControlProps {
export function SearchScopeControl(props: SearchScopeControlProps) {
const { withVariants, withSections } = props;
const [state] = useSearch();
const language = useLanguage();
const state = useSearchState();
if (!state) {
return null;
@@ -33,7 +32,7 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
return (
<div className="flex items-center gap-1">
<span className="mr-1">{t(language, 'search_scope_title')}</span>
<SearchScopeTitle />
{withSections ? (
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
) : null}
@@ -45,11 +44,16 @@ export function SearchScopeControl(props: SearchScopeControlProps) {
);
}
function SearchScopeTitle() {
const language = useLanguage();
return <span className="mr-1">{t(language, 'search_scope_title')}</span>;
}
function SearchScopeSectionControl(props: SearchScopeControlProps & { isExtended: boolean }) {
const { isExtended, section } = props;
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return (
<DropdownMenu
@@ -110,7 +114,7 @@ function SearchScopeVariantControl(props: SearchScopeControlProps & { isExtended
const { isExtended, spaceTitle, withSections } = props;
const language = useLanguage();
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return (
<DropdownMenu
@@ -388,17 +388,19 @@ async function transformSitePageResult(
};
const pageSections = await Promise.all(
pageItem.sections?.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? []
pageItem.sections
?.filter((section) => section.title || section.body)
.map<Promise<ComputedSectionResult>>(async (section) => ({
type: 'section',
id: `${page.id}/${section.id}`,
title: section.title,
href: spaceURL
? linker.toLinkForContent(joinPathWithBaseURL(spaceURL, section.path))
: linker.toPathInSpace(pageItem.path),
body: section.body,
pageId: pageItem.id,
spaceId: spaceItem.id,
})) ?? []
);
return [page, ...pageSections];
@@ -1,6 +1,12 @@
'use client';
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
import {
type Values,
parseAsBoolean,
parseAsString,
parseAsStringLiteral,
useQueryStates,
} from 'nuqs';
import React from 'react';
import type { LinkProps } from '../primitives';
@@ -36,13 +42,24 @@ export type UpdateSearchState = (
update: React.SetStateAction<SearchState | null>
) => Promise<URLSearchParams>;
const SetSearchStateContext = React.createContext<UpdateSearchState | undefined>(undefined);
const SearchStateContext = React.createContext<SearchState | null | undefined>(undefined);
/**
* Context to share the search state updater so all consumers use the same instance.
* Normalize the raw state to handle legacy formats.
*/
export const SearchContext = React.createContext<{
state: SearchState | null;
setState: UpdateSearchState;
} | null>(null);
function normalizeRawState(values: Values<typeof keyMap>) {
// Convert legacy format: q=query&ask=true -> ask=query&q=null
if (values.ask === 'true' && values.q) {
return { ...values, q: null, ask: values.q };
}
// Handle legacy global=true
if (values.global === true) {
return { ...values, scope: 'all' as const, global: null };
}
return values;
}
export function SearchContextProvider(props: React.PropsWithChildren): React.ReactElement {
const { children } = props;
@@ -52,38 +69,18 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
history: 'replace',
});
React.useEffect(() => {
// Handle legacy ask=true format by converting it to the new format
if (rawState?.ask === 'true' && rawState?.q) {
// Convert legacy format: q=query&ask=true -> ask=query&q=null
setRawState({
q: null,
ask: rawState.q,
});
}
// Handle legacy global=true
if (rawState?.global === true) {
setRawState({
scope: 'all',
global: null, // Remove the legacy parameter
});
}
}, [rawState, setRawState]);
// Local UI state for the popover open/close (not in URL)
const [open, setIsOpen] = React.useState(() => {
return rawState?.q !== null;
});
const [open, setIsOpen] = React.useState(() => normalizeRawState(rawState).q !== null);
const state = React.useMemo<SearchState | null>(() => {
if (rawState === null || (rawState.q === null && rawState.ask === null)) {
const normalized = normalizeRawState(rawState);
if (normalized.q === null && normalized.ask === null) {
return null;
}
return {
query: rawState.q,
ask: rawState.ask,
scope: rawState.scope,
query: normalized.q,
ask: normalized.ask,
scope: normalized.scope,
open,
};
}, [rawState, open]);
@@ -116,18 +113,29 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
[setRawState]
);
return <SearchContext.Provider value={{ state, setState }}>{children}</SearchContext.Provider>;
return (
<SearchStateContext.Provider value={state}>
<SetSearchStateContext.Provider value={setState}>
{children}
</SetSearchStateContext.Provider>
</SearchStateContext.Provider>
);
}
/**
* Hook to access the current search query and update it.
*/
export function useSearch(): [SearchState | null, UpdateSearchState] {
const ctx = React.useContext(SearchContext);
if (!ctx) {
throw new Error('useSearch must be used within SearchContextProvider');
export function useSetSearchState() {
const setState = React.useContext(SetSearchStateContext);
if (setState === undefined) {
throw new Error('useSetSearchState must be used within SearchContextProvider');
}
return [ctx.state, ctx.setState];
return setState;
}
export function useSearchState() {
const state = React.useContext(SearchStateContext);
if (state === undefined) {
throw new Error('useSearchState must be used within SearchContextProvider');
}
return state;
}
/**
@@ -137,7 +145,7 @@ export function useSearchLink(): (
params: Partial<SearchState>,
callback?: () => void
) => LinkProps {
const [, setSearchState] = useSearch();
const setSearchState = useSetSearchState();
return React.useCallback(
(params, callback) => {
@@ -68,14 +68,10 @@ export async function SiteLayout(props: {
</SpaceLayout>
</AIContextProvider>
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} />
))}
</>
) : null}
<LoadIntegrations />
{scripts.length > 0
? scripts.map(({ script }) => <script key={script} async src={script} />)
: null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
@@ -8,6 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useSpaceBasePath } from '../SpaceLayout/SpaceLayoutContext';
import { CurrentPageProvider } from '../hooks';
import { SuspenseLoadedHint } from '../primitives';
/**
* Component that displays a "page not found" message.
@@ -20,11 +21,18 @@ export function SitePageNotFound() {
// If ?fallback=true, redirect to the root page.
const fallback = searchParams?.get('fallback');
const ask = searchParams?.get('ask');
useEffect(() => {
if (fallback) {
router.replace(basePath);
} else if (ask) {
// This is a workaround to prevent an infinite loop when the ask parameter is present.
// If it is present while we are on a 404 page, Next.js will fetch the RSC of the page again,
// trigerring the page to rerender and the assistant to start again, causing the infinite loop.
// To prevent this, when we detect the ask parameter, we redirect to the base path where the error does not occur.
router.replace(`${basePath}?${searchParams?.toString()}`);
}
}, [basePath, fallback, router]);
}, [basePath, fallback, ask, router, searchParams]);
return (
<CurrentPageProvider page={null}>
@@ -45,6 +53,7 @@ export function SitePageNotFound() {
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
<SuspenseLoadedHint />
{/* Track the page not found as a page view */}
<TrackPageViewEvent displayContext={SiteInsightsDisplayContext.Site} />
@@ -18,7 +18,9 @@ import type {
} from './encodeClientSiteSections';
const SCREEN_OFFSET = 16; // 1rem
const MAX_ITEMS_PER_COLUMN = 8; // number of items per column
const MAX_ITEMS_PER_COLUMN = 10; // number of items per column
const GROUP_MASONRY_THRESHOLD = 6; // if a section group has more than this many child groups, it will be shown in a masonry grid
/**
* A set of navigational links representing site sections for multi-section sites
*/
@@ -227,7 +229,7 @@ function SectionGroupTileList(props: {
{hasSections && (
<ul
className={tcls(
'flex min-w-48 grid-flow-row flex-col gap-x-2 gap-y-1 self-start p-3 md:grid',
'flex min-w-48 shrink-0 grid-flow-row flex-col gap-x-2 gap-y-1 self-start p-3 md:grid',
hasGroups ? 'bg-tint-base' : ''
)}
style={{
@@ -248,7 +250,10 @@ function SectionGroupTileList(props: {
{hasGroups && (
<ul
className={tcls(
'flex min-w-48 flex-col items-start justify-start gap-x-8 gap-y-2 p-3 md:flex-row md:gap-y-8',
'grow gap-x-8 space-y-8 p-3',
groups.length > GROUP_MASONRY_THRESHOLD
? 'w-screen md:columns-[15rem]'
: 'flex flex-col justify-start md:flex-row md:items-start',
hasSections
? 'border-tint-subtle bg-tint-subtle max-md:border-t md:border-l'
: ''
@@ -321,8 +326,8 @@ function SectionGroupTile(props: {
const { title, icon, children } = child;
return (
<li className="flex shrink-0 flex-col gap-1">
<div className="mt-3 mb-2 flex gap-2.5 px-3 font-semibold text-tint-subtle text-xs uppercase tracking-wider">
<li className="flex shrink-0 break-inside-avoid flex-col gap-1">
<div className="mt-3 mb-1 flex gap-2.5 px-3 font-semibold text-tint-subtle text-xs uppercase tracking-wider">
{icon && (
<SectionIcon className="mt-0.5" isActive={false} icon={icon as IconName} />
)}
@@ -105,11 +105,13 @@ export function SpaceLayout(props: SpaceLayoutProps) {
const withSections = Boolean(visibleSections && visibleSections.list.length > 1);
const variants = categorizeVariants(context);
const socialLinks = customization.socialAccounts.filter((account) => account.display?.footer);
const withFooter =
customization.themes.toggeable ||
customization.footer.copyright ||
customization.footer.logo ||
socialLinks.length > 0 ||
customization.footer.groups?.length;
return (
@@ -12,7 +12,7 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
const { page } = props;
return (
<li className="flex flex-col">
<li className="page-document-item flex flex-col [.page-group-item+&]:mt-4">
<ToggleableLinkItem
href={page.href ?? '#'}
pathnames={page.pathnames}
@@ -11,7 +11,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
const { page, isFirst } = props;
return (
<li className="flex flex-col">
<li className="page-group-item flex flex-col">
<div
className={tcls(
'-top-4 sticky z-1 flex items-center gap-3 px-3',
@@ -27,7 +27,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
isFirst ? '-mt-4' : ''
isFirst ? '-mt-2 -top-2 rounded-t-2xl pt-2' : ''
)}
>
<TOCPageIcon page={page} />
@@ -15,7 +15,7 @@ export function PageLinkItem(props: { page: ClientTOCPageLink }) {
const isExternal = page.target.kind === 'url';
return (
<li className={tcls('flex', 'flex-col')}>
<li className="page-link-item flex flex-col [.page-group-item+&]:mt-4">
<Link
href={page.href ?? '#'}
classNames={['ToggleableLinkItemStyles']}
@@ -1,5 +1,4 @@
import type { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { getSpaceLanguage, tString } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
@@ -49,7 +48,7 @@ export function Trademark(props: {
className
)}
icon={<Icon icon="gitbook" className="size-5 shrink-0" />}
icon="gitbook"
label={tString(language, 'powered_by_gitbook')}
insights={{
type: 'trademark_click',
@@ -12,7 +12,6 @@ export function useIsMobile(breakpoint = 1024, container?: string): boolean {
// In the case of a container we need a ResizeObserver, since the matchMedia listener might fire at different times than the container size changes.
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
console.log('checked', entry.contentRect.width < breakpoint);
setIsMobile(entry.contentRect.width < breakpoint);
});
});
@@ -28,9 +28,9 @@ export const variantClasses = {
primary: [
'bg-primary-original',
'text-contrast-primary-original',
'hover:bg-primary-solid-hover',
'hover:border-primary-solid-hover',
'hover:text-contrast-primary-solid-hover',
'hover:not-disabled:bg-primary-solid-hover',
'hover:not-disabled:border-primary-solid-hover',
'hover:not-disabled:text-contrast-primary-solid-hover',
'border-primary-original',
'contrast-more:bg-primary-solid',
'contrast-more:text-contrast-primary-solid',
@@ -45,8 +45,8 @@ export const variantClasses = {
'contrast-more:border',
'shadow-none!',
'translate-y-0!',
'hover:bg-tint-hover',
'hover:text-tint-strong',
'hover:not-disabled:bg-tint-hover',
'hover:not-disabled:text-tint-strong',
'focus-visible:bg-tint-hover',
'focus-visible:text-tint-strong',
'data-[state=open]:bg-tint-hover',
@@ -60,8 +60,8 @@ export const variantClasses = {
'depth-flat:bg-transparent',
'text-tint',
'hover:bg-tint-hover',
'hover:depth-flat:bg-tint-hover',
'hover:text-tint',
'hover:not-disabled:depth-flat:bg-tint-hover',
'hover:not-disabled:text-tint',
'contrast-more:bg-tint-subtle',
'disabled:bg-transparent',
'disabled:text-tint/8',
@@ -70,12 +70,12 @@ export const variantClasses = {
'bg-tint-base text-tint',
'hover:theme-clean:bg-tint-subtle',
'theme-bold:bg-header-link/2',
'theme-bold:bg-header-link/1',
'theme-bold:text-header-link',
'theme-bold:shadow-none!',
'theme-bold:border-header-link/4',
'hover:theme-bold:bg-header-link/3',
'hover:theme-bold:bg-header-link/2',
'hover:theme-bold:text-header-link',
'hover:theme-bold:shadow-none',
'hover:theme-bold:border-header-link/5',
@@ -144,22 +144,21 @@ export const Button = React.forwardRef<
};
let iconElement = null;
if (icon) {
if (React.isValidElement(icon)) {
type IconElement = React.ReactElement<React.SVGProps<SVGSVGElement>>;
iconElement = React.cloneElement(icon as IconElement, {
className: tcls(
'button-leading-icon shrink-0',
iconSizeClasses[size],
(icon as IconElement).props.className
),
});
} else {
if (typeof icon === 'string') {
iconElement = (
<Icon
icon={icon as IconName}
className={tcls('button-leading-icon shrink-0', iconSizeClasses[size])}
/>
);
} else if (React.isValidElement<React.SVGProps<SVGSVGElement>>(icon)) {
iconElement = React.cloneElement(icon, {
className: tcls(
'button-leading-icon shrink-0',
iconSizeClasses[size],
icon.props.className
),
});
}
}
@@ -32,15 +32,37 @@ type CustomInputProps = {
* A keyboard shortcut, shown to the right of the input.
*/
keyboardShortcut?: boolean | KeyboardShortcutProps;
onSubmit?: (value: string | number | readonly string[] | undefined) => void;
/**
* Callback invoked when the user submits the input by pressing Enter.
* Only called if the input has a non-empty value.
*/
onSubmit?: (value: string) => void;
/**
* Controlled value of the input. When provided, the input becomes a controlled component.
*/
value?: string;
/**
* Callback invoked whenever the input value changes.
* Used to update the parent component's state in controlled mode.
*/
onValueChange?: (value: string) => void;
/**
* When true, automatically resizes the textarea vertically to fit its content.
* Only applies when multiline is true.
*/
resize?: boolean;
};
export type InputProps = CustomInputProps &
(
| ({ multiline?: false } & React.InputHTMLAttributes<HTMLInputElement>)
| ({ multiline: true } & React.TextareaHTMLAttributes<HTMLTextAreaElement>)
| ({ multiline?: false } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange'
>)
| ({ multiline: true } & Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'value' | 'onChange'
>)
);
type InputElement = HTMLInputElement | HTMLTextAreaElement;
@@ -72,7 +94,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
'aria-busy': ariaBusy,
placeholder,
disabled,
onChange,
onValueChange,
onKeyDown,
maxLength,
minLength,
@@ -80,7 +102,7 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
...htmlProps
} = props;
const [value, setValue] = useControlledState(passedValue, passedValue ?? '');
const [value, setValue] = useControlledState(passedValue, passedValue ?? '', onValueChange);
const [submitted, setSubmitted] = React.useState(false);
const [height, setHeight] = React.useState<number>();
const inputRef = React.useRef<InputElement>(null);
@@ -120,7 +142,6 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
const handleChange = (event: React.ChangeEvent<InputElement>) => {
const newValue = event.target.value;
setValue(newValue);
onChange?.(event as React.ChangeEvent<HTMLInputElement & HTMLTextAreaElement>);
// Reset submitted state when user edits the value to allow re-submission
if (submitted) {
@@ -156,7 +177,12 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
// If the user wants to handle the keydown by itself, we let him do it.
if (event.defaultPrevented) return;
if (event.key === 'Enter' && !event.shiftKey && hasValue) {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
hasValue
) {
event.preventDefault();
handleSubmit();
} else if (event.key === 'Escape') {
@@ -2,38 +2,19 @@
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import * as React from 'react';
import type * as React from 'react';
export type KeyboardShortcutProps = {
keys: string[];
} & React.HTMLAttributes<HTMLDivElement>;
function getOperatingSystem() {
const platform = navigator.platform.toLowerCase();
if (platform.includes('mac')) return 'mac';
if (platform.includes('win')) return 'win';
return 'win';
}
export function KeyboardShortcut(props: KeyboardShortcutProps) {
const { keys, className } = props;
const [operatingSystem, setOperatingSystem] = React.useState<string | null>(null);
React.useEffect(() => {
setOperatingSystem(getOperatingSystem());
}, []);
return (
<div
aria-busy={operatingSystem === null ? 'true' : undefined}
className={tcls(
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex',
operatingSystem
? 'motion-safe:animate-fade-in motion-reduce:opacity-11'
: 'opacity-0'
'shortcut hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt","case"] contrast-more:text-tint-strong md:flex'
)}
>
{keys.map((key, index) => {
@@ -41,7 +22,12 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
switch (key) {
case 'mod':
element = operatingSystem === 'mac' ? '⌘' : 'Ctrl';
element = (
<>
<span className="hidden [html.os-mac_&]:inline">⌘</span>
<span className="inline [html.os-mac_&]:hidden">Ctrl</span>
</>
);
break;
case 'enter':
@@ -53,11 +39,7 @@ export function KeyboardShortcut(props: KeyboardShortcutProps) {
key={index}
className={tcls(
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
key === 'mod'
? operatingSystem === 'mac'
? 'text-sm'
: 'text-xs'
: 'uppercase',
key === 'mod' ? 'text-xs [html.os-mac_&]:text-sm' : 'uppercase',
className
)}
>
@@ -18,13 +18,17 @@ export const NavigationLoader = () => {
void pathname;
const root = document.documentElement;
root.classList.add('route-change');
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
root.classList.remove('route-change');
});
return () => cancelAnimationFrame(raf2);
});
return () => cancelAnimationFrame(raf1);
return () => {
root.classList.remove('route-change');
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [pathname]);
return (
@@ -33,7 +33,7 @@ export type ImageResponsiveSize = {
width: number;
};
const MAX_DPR = 4;
const MAX_DPR = 3;
interface ImageCommonProps {
/**
@@ -110,7 +110,7 @@ export function Image(
} & ImageCommonProps
>
) {
const { sources, style, inline = false, alt, ...rest } = props;
const { sources, style, inline = false, alt, loading, ...rest } = props;
return (
<>
@@ -125,6 +125,7 @@ export function Image(
style
)}
alt={sources.light.alt || alt}
loading={loading}
/>
{sources.dark ? (
<ImagePicture
@@ -133,7 +134,7 @@ export function Image(
inline={inline}
// We don't want to preload the dark image, because it's not visible
// TODO: adapt based on the default theme
loading="lazy"
loading={loading === 'eager' ? 'eager' : 'lazy'}
className={tcls(
rest.className,
'hidden',
@@ -79,6 +79,8 @@ export const de = {
pdf_download: 'Als PDF exportieren',
pdf_goback: 'Zurück zum Inhalt',
pdf_print: 'Drucken oder als PDF speichern',
pdf_images_loading: 'Bilder werden geladen (${1}/${2})',
pdf_images_loaded: 'Alle Bilder geladen',
pdf_page_of: '${1} von ${2}',
pdf_mode_only_page: 'Nur diese Seite',
pdf_mode_all: 'Alle Seiten',
@@ -77,6 +77,8 @@ export const en = {
pdf_download: 'Export as PDF',
pdf_goback: 'Go back to content',
pdf_print: 'Print or Save as PDF',
pdf_images_loading: 'Images loading (${1}/${2})',
pdf_images_loaded: 'All images loaded',
pdf_page_of: '${1} of ${2}',
pdf_mode_only_page: 'Only this page',
pdf_mode_all: 'All pages',
@@ -80,6 +80,8 @@ export const es: TranslationLanguage = {
pdf_download: 'Exportar como PDF',
pdf_goback: 'Volver al contenido',
pdf_print: 'Imprimir o Guardar como PDF',
pdf_images_loading: 'Cargando imágenes (${1}/${2})',
pdf_images_loaded: 'Todas las imágenes cargadas',
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Solo esta página',
pdf_mode_all: 'Todas las páginas',

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