Compare commits

..

115 Commits

Author SHA1 Message Date
Tal Gluck 50cafe9ec6 linting 2025-12-03 14:24:35 +01:00
Tal Gluck 2f05549c72 update snippet generator 2025-12-03 12:19:41 +01:00
Tal Gluck 2da0ccf4ba update code sample generator for python 2025-12-03 12:03:29 +01:00
Zeno Kapitein 9e062b10d3 Make semantic text colors more vibrant (#3838) 2025-12-03 10:39:41 +01:00
Nolann B. 74f89316d9 Fix hidden section not found (#3836) 2025-12-02 11:37:55 +01:00
Zeno Kapitein 9e1d7b296b Update AIChat context card open delay (#3834) 2025-12-02 10:24:18 +01:00
Nolann B. b49caafa9c Support for hidden sections (#3831) 2025-12-01 14:48:48 +01:00
Zeno Kapitein f372eec393 Make annotations visible on hover, fix accessibility and outro animations (#3822) 2025-11-28 17:15:29 +01:00
Zeno Kapitein 65ae941687 Replace AI Chat generated content in e2e (#3833) 2025-11-28 16:46:04 +01:00
Viktor Renkema 75b8efa155 Remove sticky prop on Updates block (#3832) 2025-11-28 10:08:21 +01:00
Utku Ufuk 34b0c67c91 Stop filtering by language when site space language is unspecified (#3825) 2025-11-27 17:57:57 +03:00
Zeno Kapitein e0ace99707 Fix flaky tests in AI Chat greeting (#3828) 2025-11-27 09:50:03 +01:00
Viktor Renkema 3553db0989 Add RSS feed for pages with update blocks (#3824)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-11-26 20:34:25 +01:00
Zeno Kapitein 5c14948cf3 Prevent flaky tests in AI Chat followup suggestions (#3827) 2025-11-26 18:39:51 +01:00
conico974 8e4e2a2333 Update wranglerVersion to 4.43.0 (#3826) 2025-11-26 18:35:18 +01:00
Zeno Kapitein 0f4c53cfbd Update Assistant e2e tests (#3814) 2025-11-26 17:09:48 +00:00
Zeno Kapitein c156e946b0 Update Assistant styling (#3797) 2025-11-24 12:41:00 +01:00
Nolann B. 461e15fd00 Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme (#3820) 2025-11-22 10:44:05 +01:00
conico974 2989f79708 Add 'use server' directive to markdown renderer (#3815) 2025-11-20 09:28:00 +01:00
Nolann B. 344842f5ce Improve OpenAPI circular references (#3821) 2025-11-19 22:44:32 +01:00
Samy Pessé c51076efc4 Fix links to other spaces in embeddable view (#3819) 2025-11-19 10:38:53 +00:00
Zeno Kapitein 38e34dcce3 Optimise page outline (#3813) 2025-11-19 11:26:16 +01:00
Nolann B. 04a4175678 Remove hidden site spaces from navigation (#3795) 2025-11-18 22:49:41 +01:00
Nolann B. 3e40b4db50 Fix OpenAPI basic auth placeholder (#3816) 2025-11-18 20:18:59 +01:00
Greg Bergé 854e612e5d Fix handling of broken links in buttons (#3812) 2025-11-18 11:12:27 +01:00
Samy Pessé 27a461be9d Fix NPM publishing once and for all (#3811) 2025-11-16 02:16:20 +01:00
Samy Pessé 3d83dadb12 New attempt at fixing NPM publishing with trusted publisher (#3810) 2025-11-16 01:34:57 +01:00
Samy Pessé 8dc5c18b9f Pass proper GITHUB_TOKEN to the publish script 2025-11-16 00:35:16 +01:00
Samy Pessé e4756c23a7 Use dedicated step to publish to NPM 2025-11-16 00:28:37 +01:00
Samy Pessé 79ad7bcb59 Fix publishing to NPM (#3809) 2025-11-16 00:25:26 +01:00
Samy Pessé 5b0917a13a Remove --dry-run in publish script 2025-11-15 23:36:35 +01:00
Samy Pessé 6d6f4ef6dd Fix the script to publish npm packages and make it more robust 2025-11-15 23:31:14 +01:00
Samy Pessé fa69a64a2b Merge branch 'main' of https://github.com/GitbookIO/gitbook 2025-11-15 19:47:52 +01:00
Samy Pessé d1a85d1f91 Try fixing the publishing workflow 2025-11-15 19:47:36 +01:00
Samy Pessé 05bf9d7a59 Version Packages (#3733)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-15 19:34:29 +01:00
Samy Pessé 2e0b3370c8 Fix permissions for publish workflow 2025-11-15 19:32:41 +01:00
Samy Pessé 10995e09ca Use trusted publishing for publishing NPM packages (#3808) 2025-11-15 19:26:26 +01:00
Samy Pessé e7cc3ff61f Revert "Use NPM dependency for eval-estree-expression"
This reverts commit 59d259074b.
2025-11-15 18:13:10 +01:00
Samy Pessé 59d259074b Use NPM dependency for eval-estree-expression 2025-11-15 17:58:54 +01:00
Zeno Kapitein 96c4ae4f2d Fix Heading in hint blocks are huge in published content (#3806) 2025-11-14 15:26:46 +01:00
Taran Vohra 64f3596df2 Output generic alternates for page metadata (#3804) 2025-11-14 16:32:19 +05:30
Taran Vohra 27d6c3af39 Use an available alternate when switching variants in a page (#3796) 2025-11-14 15:19:40 +05:30
Nolann B. 1de4eb7973 Fix @modelcontextprotocol/sdk version (#3805) 2025-11-13 15:38:33 +01:00
Nolann B. df1966df25 Bump Scalar (#3803) 2025-11-13 15:29:25 +01:00
conico974 1001aaf94e Refactor DynamicTabs to use NavigationStatusContext (#3800) 2025-11-13 14:37:09 +01:00
Zeno Kapitein 155e448c8f Support primary link in logo (#3802) 2025-11-13 12:59:17 +01:00
Viktor Renkema ca2a94ee1a Support sticky date + new third date format (#3801) 2025-11-13 12:09:53 +01:00
Nolann B. a512c90bda Re-arrange OpenAPI Scopes for OAuth2 (#3799) 2025-11-12 19:40:21 +01:00
conico974 9cbefa89c7 Filter invalid events sent to the API (#3792) 2025-11-12 11:54:13 +01:00
Taran Vohra c8bba7bfe1 Resolve page meta links for metadata (#3793) 2025-11-12 16:12:27 +05:30
Viktor Renkema 4e04db8a1b Support rendering upcoming Update blocks (#3794) 2025-11-12 10:16:02 +01:00
Nolann B. b45feafeff Disable OpenAPI "Try it" when no servers are defined (#3791) 2025-11-10 17:25:22 +01:00
Zeno Kapitein 3876f7081e Edgeless Assistant window on depth: flat sites (#3788) 2025-11-10 14:37:28 +01:00
Nolann B. b4a021a192 Add heredoc support for cURL JSON body (#3790) 2025-11-10 11:20:53 +01:00
Zeno Kapitein 1d92dc1350 Update site layout and blocks with container queries (#3780) 2025-11-10 10:55:03 +01:00
Nolann B. e4ba35fdae Rework OpenAPI Authorizations + scopes (#3786) 2025-11-06 21:20:09 +01:00
Viktor Renkema 35dcaca971 Support custom cover heights (#3779) 2025-11-05 14:21:47 +01:00
Zeno Kapitein 2aa4f2e0b8 Update DropdownMenu styling (#3787) 2025-11-05 08:40:53 +01:00
Zeno Kapitein 6d56df7c10 Treat pages without visible TOC items as no-toc (#3783) 2025-11-05 08:40:15 +01:00
Zeno Kapitein 4534ea83c3 Implement unique title tags for sections & variants (#3768) 2025-11-04 11:36:28 +01:00
Zeno Kapitein 9022d87ec7 Add page breadcrumbs to search results (#3784) 2025-11-04 00:31:32 +01:00
Brett Jephson a49781e200 Expand code block when printing (#3782) 2025-11-03 15:26:18 +00:00
Steven H 065a5e6e55 Disable Workers Observability (#3781) 2025-11-03 11:17:33 +00:00
Zeno Kapitein e27986b17a Reword "Everywhere" search scope to "All docs" (#3776) 2025-11-03 09:57:21 +01:00
Zeno Kapitein 8606f222c9 Add padding to TOC page group items (#3777) 2025-11-03 09:56:39 +01:00
Nolann B. 2c3066e3ae Improve OAuth2 scopes handling in OpenAPI (#3775) 2025-10-30 15:46:29 +01:00
Zeno Kapitein 55c0b03a41 Support translations and generic variants together (#3772) 2025-10-30 15:03:50 +01:00
conico974 3e548e4186 Initialize hash on mount and improve navigation handling (#3774) 2025-10-30 14:33:26 +01:00
Viktor Renkema 3aea302648 Revert "Support cover image heights and positioning changes" (#3771) 2025-10-30 10:24:22 +01:00
Zeno Kapitein 1d1f0a7c6a Fix search container shadow (#3770) 2025-10-29 11:14:31 +01:00
conico974 590729cce5 Redirect to space base path on fallback true (#3745) 2025-10-29 10:55:27 +01:00
conico974 b554d7d13f Add isRoot prop to PagesList for root header offset (#3759) 2025-10-29 10:50:53 +01:00
conico974 9568b8a7c8 Add keep_names option to wrangler configurations (#3769) 2025-10-29 10:00:19 +01:00
Zeno Kapitein 683bb8f634 Add confirmation to "Copy MCP URL" page action (#3763) 2025-10-28 12:42:02 +01:00
Zeno Kapitein 7d6a31627f Revert contentref anchor title (#3767) 2025-10-28 12:27:59 +01:00
Brett Jephson c31e5648f5 RND-8495: page group emoji and icon resolution (#3766) 2025-10-28 10:23:08 +00:00
Zeno Kapitein 0c01832684 SearchInput fixes (#3762) 2025-10-28 09:50:31 +01:00
Zeno Kapitein 6b1c9fef86 Fix opacity of ScrollContainer buttons on bold themes (#3764) 2025-10-28 09:37:23 +01:00
Claire Chabas cc976995cc Fix image frame width (#3765) 2025-10-27 20:01:12 +01:00
Nolann B. ebbb476589 Improve ancestor label in references (#3761) 2025-10-27 16:49:48 +01:00
Greg Bergé 89655cb428 Fix reset scroll issue (#3760) 2025-10-27 16:41:39 +01:00
Zeno Kapitein 3676d64bdc Update search scope UI (#3750) 2025-10-27 15:57:47 +01:00
Viktor Renkema f682df1e01 Support cover image heights and positioning changes (#3758) 2025-10-27 15:55:50 +01:00
Greg Bergé 6c9893945d Fix scrolling hash into tabs / expandable (#3757) 2025-10-25 15:30:52 +02:00
Greg Bergé a2fb627ae1 Fix scroll jump and scroll reset (#3756) 2025-10-25 13:04:34 +02:00
Nolann B. f9f80117f0 Add alt text support to card covers (#3715) 2025-10-24 16:27:23 +02:00
conico974 63bf4dbb16 Fix cross-space content reference resolution and enhance anchor text rendering (#3754) 2025-10-24 15:26:59 +02:00
Brett Jephson b3fa503aff Expandable code block (#3753) 2025-10-24 14:01:31 +01:00
Zeno Kapitein 0ae7cfd790 Improve AI Search branding (#3752) 2025-10-24 14:36:31 +02:00
Brett Jephson 8fdc5c89dc Fix: height issues with webframe (#3749) 2025-10-24 10:29:46 +01:00
Zeno Kapitein 758b34d46b Move the "Ask <query>" to the bottom of search results if query is not a question (#3751) 2025-10-24 10:58:51 +02:00
Zeno Kapitein 1b0613e482 Tweak language dropdown styling (#3748) 2025-10-22 22:00:52 +02:00
conico974 3164f1a99d Fix preloading images (#3747) 2025-10-22 15:09:42 +02:00
Greg Bergé 6b588af07d Clarify CONTRIBUTING.md (#3742) 2025-10-22 12:43:28 +00:00
Greg Bergé d436b04ff4 Fix jump when clicking tabs (#3746) 2025-10-21 18:30:33 +02:00
conico974 d159788d0b Add end-to-end tests for table of contents navigation (#3741) 2025-10-21 12:39:23 +02:00
Nolann B. 3355960d95 Fix ToggeableLinkItem display (#3744)
Co-authored-by: Zeno Kapitein <zeno@gitbook.io>
2025-10-21 10:32:17 +02:00
Nolann B. c32d4494f2 Fix OpenAPI path display (#3743) 2025-10-19 21:44:26 +02:00
conico974 1dcf4df51f Fix table of contents display issue (#3740) 2025-10-17 19:20:33 +00:00
conico974 3c0e49d56b Fix cache race condition (#3738) 2025-10-17 19:42:16 +02:00
Greg Bergé c35d0e8954 Run tests automatically when "Version packages" PR is opened (#3737) 2025-10-17 14:28:38 +02:00
Greg Bergé c0d73d2f76 Improve tabs (#3735) 2025-10-17 12:13:09 +02:00
Viktor Renkema 454175ab86 Change copy of toolbar button (#3736) 2025-10-17 07:17:39 +00:00
Greg Bergé 6feb8fcd6a Support emojis in outline (#3734) 2025-10-16 23:49:12 +02:00
conico974 b8d3cb7ee5 Improve search error handling and token redirection (#3732) 2025-10-16 15:39:32 +02:00
Nolann B. 8ce73229c3 Add OpenAPI servers selection (#3726) 2025-10-16 14:27:42 +02:00
Greg Bergé 8eae6e970b Upgrade to bun 1.3 (#3731) 2025-10-16 10:22:52 +02:00
Greg Bergé a48b886dfd Add button to download file (#3730) 2025-10-15 17:46:47 +02:00
Greg Bergé 17de0838c5 Allow to split pages between list items (#3729) 2025-10-15 14:02:41 +02:00
Greg Bergé baf3496980 Improve inline tooltip performances (#3728) 2025-10-15 13:30:38 +02:00
Samy Pessé ae33c09aa7 Version Packages (#3724)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-15 13:30:24 +02:00
Greg Bergé 7ff69c3b72 Fix bun token (#3727) 2025-10-15 10:43:44 +02:00
Greg Bergé 6142d6bf80 Build all packages using tsdown (#3722) 2025-10-14 14:39:19 +02:00
Brett Jephson 75543b140f Update Node version to work with globalThis.process.getBuiltinModule (#3725) 2025-10-14 11:13:26 +01:00
Nolann B. 511f1cfbe5 Add scroll margin to Expandable (#3723) 2025-10-13 15:54:32 +02:00
283 changed files with 6546 additions and 3520 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
---
Add x-gitbook-prefix and x-gitbook-token-placeholder for OpenAPI security scheme
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix OpenAPI basic auth placeholder
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix links to other spaces and root page in embeddable view.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Add a RSS feed to all pages with update blocks.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Remove hidden site spaces from navigation
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix hidden section not found
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI circular references
+3 -4
View File
@@ -54,10 +54,9 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally #### 3. Install dependencies and run the project locally
##### Prerequisites: ##### Prerequisites:
- Node.js (Version: >=20.6)
- Use `nvm` for easy Node management - [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
- We use a text-based lockfile which isn't supported below 1.2.15
##### Setup steps: ##### Setup steps:
@@ -30,7 +30,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -49,7 +49,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -61,7 +61,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
@@ -71,7 +71,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -70,7 +70,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
@@ -81,7 +81,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
@@ -104,7 +104,7 @@ runs:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '4.43.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
+3 -2
View File
@@ -4,8 +4,9 @@ on:
push: push:
branches: branches:
- main - main
- changeset-release/main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -41,7 +42,7 @@ jobs:
name: Build (Open Source) name: Build (Open Source)
timeout-minutes: 6 timeout-minutes: 6
env: env:
NPM_TOKEN_READONLY: '' BUN_NPM_TOKEN: ''
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1 -1
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (preview) name: Deploy v2 to Vercel (preview)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (production) name: Deploy v2 to Vercel (production)
+1 -1
View File
@@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (staging) name: Deploy v2 to Vercel (staging)
+16 -8
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }} concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -14,30 +14,38 @@ jobs:
publish: publish:
name: Publish name: Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
pull-requests: write
contents: write
steps: steps:
- name: Checkout Repo - name: Checkout Repo
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0 fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: Setup Bun - name: Setup Bun
uses: ./.github/composite/setup-bun uses: ./.github/composite/setup-bun
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
env: env:
PUPPETEER_SKIP_DOWNLOAD: 1 PUPPETEER_SKIP_DOWNLOAD: 1
- name: Create Release Pull Request or Publish to npm - name: Create Release Pull Request
id: changesets id: changesets
uses: changesets/action@v1 uses: changesets/action@v1
with: with:
publish: npm run release publish: bun run publish-all-packages
version: npm run changeset-version version: bun run changeset-version
env: env:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created # Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+4
View File
@@ -25,3 +25,7 @@ yarn-error.log*
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
.npmrc
# Bun pack artifacts
packages/*/*.tgz
+1 -1
View File
@@ -1 +1 @@
v20.6 v22.3
+2 -2
View File
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites ### Prerequisites
- Node.js (Version: >=20.6) - Node.js (Version: >= 22.3)
- Use nvm for easy Node management - Use nvm for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15) - [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15 - We use a text-based lockfile which isn't supported below 1.2.15
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
``` ```
.env.local .env.local
NPM_TOKEN_READONLY=xxx BUN_NPM_TOKEN=xxx
``` ```
and then reinstall dependencies. and then reinstall dependencies.
+501 -850
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes] [install.scopes]
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" } "gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
+24 -8
View File
@@ -1,17 +1,22 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.1.0", "version": "0.1.0",
"engines": {
"node": "^22.3.0"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.29.7", "@changesets/cli": "^2.29.7",
"turbo": "^2.5.8", "turbo": "^2.6.1",
"vercel": "^39.4.2" "vercel": "^39.4.2"
}, },
"packageManager": "bun@1.2.15", "packageManager": "bun@1.3.2",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"react": "^19.0.0", "@types/react": "catalog:",
"react-dom": "^19.0.0", "@types/react-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.24.2" "esbuild": "0.24.2"
}, },
"private": true, "private": true,
@@ -27,17 +32,28 @@
"e2e-customers": "turbo run e2e-customers", "e2e-customers": "turbo run e2e-customers",
"changeset": "changeset", "changeset": "changeset",
"changeset-version": "changeset version && bun run format && bun update", "changeset-version": "changeset version && bun run format && bun update",
"release": "turbo run release && bun run publish-all-packages", "publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful",
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean" "clean": "turbo run clean"
}, },
"workspaces": { "workspaces": {
"packages": ["packages/*"], "packages": ["packages/*"],
"catalog": { "catalog": {
"@gitbook/api": "0.143.2", "@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.151.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"assert-never": "^1.4.0",
"bidc": "^0.0.2", "bidc": "^0.0.2",
"tsdown": "^0.15.6" "bun-types": "^1.1.20",
"classnames": "^2.5.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsdown": "^0.15.6",
"typescript": "^5.5.3",
"usehooks-ts": "^3.1.1"
} }
}, },
"patchedDependencies": { "patchedDependencies": {
+16
View File
@@ -1,5 +1,21 @@
# @gitbook/browser-types # @gitbook/browser-types
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+12 -4
View File
@@ -8,21 +8,29 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.1.1", "sideEffects": false,
"version": "0.1.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:" "@gitbook/icons": "workspace:"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsdown",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/cache-tags # @gitbook/cache-tags
## 0.3.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.3.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.3.2 ## 0.3.2
### Patch Changes ### Patch Changes
+13 -7
View File
@@ -4,26 +4,32 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.2", "sideEffects": false,
"version": "0.3.4",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"assert-never": "^1.2.1" "assert-never": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "src", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/colors # @gitbook/colors
## 0.4.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.4.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.4.1 ## 0.4.1
### Patch Changes ### Patch Changes
+12 -6
View File
@@ -4,22 +4,28 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.4.1", "sideEffects": false,
"version": "0.4.3",
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "src", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+16
View File
@@ -1,5 +1,21 @@
# @gitbook/embed # @gitbook/embed
## 0.1.5
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- Updated dependencies [10995e0]
- @gitbook/icons@0.3.4
## 0.1.4
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- Updated dependencies [6142d6b]
- @gitbook/icons@0.3.3
## 0.1.3 ## 0.1.3
### Patch Changes ### Patch Changes
+11 -5
View File
@@ -12,19 +12,21 @@
"default": "./dist/react/index.js" "default": "./dist/react/index.js"
} }
}, },
"version": "0.1.3", "sideEffects": false,
"version": "0.1.5",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/icons": "workspace:", "@gitbook/icons": "workspace:",
"bidc": "catalog:" "bidc": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.0.0" "react": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "^5.5.3", "typescript": "catalog:",
"react": "^19.0.0" "react": "catalog:"
}, },
"scripts": { "scripts": {
"build": "bun run build-lib && bun run build-standalone", "build": "bun run build-lib && bun run build-standalone",
@@ -32,11 +34,15 @@
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone", "build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "bun run build -- --watch ./src" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+8 -6
View File
@@ -1,4 +1,6 @@
import React from 'react'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
GetFrameURLOptions, GetFrameURLOptions,
GitBookEmbeddableConfiguration, GitBookEmbeddableConfiguration,
@@ -17,19 +19,19 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) { export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props; const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null); const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook(); const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null); const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]); const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
React.useEffect(() => { useEffect(() => {
if (frameRef.current) { if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current)); setGitbookFrame(gitbook.createFrame(frameRef.current));
} }
}, [gitbook]); }, [gitbook]);
React.useEffect(() => { useEffect(() => {
gitbookFrame?.configure({ gitbookFrame?.configure({
buttons, buttons,
welcomeMessage, welcomeMessage,
+12 -1
View File
@@ -29,6 +29,16 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); --gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
} }
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -120,7 +130,8 @@
z-index: 9998; z-index: 9998;
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left)))); width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top)))); height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
background-color: var(--gitbook-widget-background-solid); background-color: var(--gitbook-widget-background-translucent);
backdrop-filter: blur(48px);
border: 1px solid var(--gitbook-widget-border-color); border: 1px solid var(--gitbook-widget-border-color);
border-radius: var(--gitbook-widget-radius); border-radius: var(--gitbook-widget-radius);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+7 -3
View File
@@ -2,8 +2,12 @@ import { defineConfig } from 'tsdown';
export default defineConfig([ export default defineConfig([
{ {
entry: ['src/index.ts', 'src/react/index.ts'], entry: 'src/index.ts',
dts: true, outDir: 'dist',
format: ['esm'], },
{
entry: 'src/react/index.ts',
outDir: 'dist/react',
unbundle: true,
}, },
]); ]);
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/emoji-codepoints # @gitbook/emoji-codepoints
## 0.2.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.2.1 ## 0.2.1
### Patch Changes ### Patch Changes
+3 -7
View File
@@ -1,19 +1,15 @@
{ {
"name": "@gitbook/emoji-codepoints", "name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints", "description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.1", "version": "0.2.2",
"private": true, "private": true,
"exports": "./dist/index.ts", "exports": "./dist/index.ts",
"dependencies": {}, "sideEffects": false,
"devDependencies": { "devDependencies": {
"emoji-assets": "^8.0.0" "emoji-assets": "^9.0.0"
}, },
"scripts": { "scripts": {
"generate": "bun ./build.ts", "generate": "bun ./build.ts",
"clean": "rm -rf ./dist" "clean": "rm -rf ./dist"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
} }
} }
+13
View File
@@ -1,5 +1,18 @@
# @gitbook/expr # @gitbook/expr
## 1.2.4
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
- 10995e0: Use NPM dependency for eval-estree-expression
## 1.2.3
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 1.2.2 ## 1.2.2
### Patch Changes ### Patch Changes
+14 -9
View File
@@ -1,42 +1,47 @@
{ {
"name": "@gitbook/expr", "name": "@gitbook/expr",
"description": "Safely evaluate & parse user-defined GitBook expressions.", "description": "Safely evaluate & parse user-defined GitBook expressions.",
"version": "1.2.2", "version": "1.2.4",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
"acorn": "^8.14.0", "acorn": "^8.14.0",
"acorn-loose": "8.4.0", "acorn-loose": "8.4.0",
"acorn-walk": "^8.3.4", "acorn-walk": "^8.3.4",
"assert-never": "catalog:",
"escodegen": "^2.1.0", "escodegen": "^2.1.0",
"assert-never": "^1.2.1" "eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.1.20", "@tsconfig/strictest": "catalog:",
"tsdown": "catalog:", "@tsconfig/node20": "catalog:",
"@types/escodegen": "^0.0.10",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@babel/types": "^7.26.0",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"@types/escodegen": "^0.0.10" "bun-types": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"build": "tsdown", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"unit": "bun test", "unit": "bun test",
"clean": "rm -rf ./dist", "clean": "rm -rf ./dist",
"dev": "bun run build -- --watch ./src" "dev": "bun run build -- --watch ./src",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "README.md", "CHANGELOG.md"], "files": ["dist", "README.md", "CHANGELOG.md"],
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/fonts # @gitbook/fonts
## 0.1.3
### Patch Changes
- 10995e0: Use NPM Trusted publishing for publishing the package.
## 0.1.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+14 -8
View File
@@ -4,32 +4,38 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.1.1", "sideEffects": false,
"version": "0.1.3",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:" "@gitbook/api": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"google-font-metadata": "^6.0.3", "bun-types": "catalog:",
"typescript": "^5.5.3" "google-font-metadata": "^6.0.7",
"tsdown": "catalog:",
"typescript": "catalog:"
}, },
"scripts": { "scripts": {
"generate": "bun ./bin/generate.js", "generate": "bun ./bin/generate.js",
"build": "tsc --project tsconfig.build.json", "build": "tsdown",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w", "dev": "bun run build -- --watch ./src",
"clean": "rm -rf ./dist && rm -rf ./src/data", "clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test" "unit": "bun test",
"publish-to-npm": "../../scripts/publish-if-new.sh"
}, },
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"], "files": ["dist", "bin", "README.md", "CHANGELOG.md"],
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
},
"repository": {
"url": "https://github.com/GitbookIO/gitbook"
} }
} }
@@ -1,57 +1,57 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`getDefaultFont should return correct object for Latin text 1`] = ` exports[`getDefaultFont should return correct object for Latin text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = ` exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should return correct object for Greek text 1`] = ` exports[`getDefaultFont should return correct object for Greek text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle mixed script text 1`] = ` exports[`getDefaultFont should handle mixed script text 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: regular 1`] = ` exports[`getDefaultFont should handle different font weights: regular 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different font weights: bold 1`] = ` exports[`getDefaultFont should handle different font weights: bold 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: inter 1`] = ` exports[`getDefaultFont should handle different fonts: inter 1`] = `
{ {
"font": "Inter", "font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff", "url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
} }
`; `;
exports[`getDefaultFont should handle different fonts: roboto 1`] = ` exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{ {
"font": "Roboto", "font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff", "url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff",
} }
`; `;
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
+75
View File
@@ -1,5 +1,80 @@
# gitbook # gitbook
## 0.20.0
### Minor Changes
- 1d92dc1: Update site layout and blocks with container queries and better transitions
### Patch Changes
- 8fdc5c8: Fix webframe height issue
- 9022d87: Add breadcrumbs to search results
- 0c01832: SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
- 1d1f0a7: Fix search container shadow
- 2aa4f2e: Update dropdown menu styling
- a49781e: Printable collapsed code block
- 7d6a316: Revert contentref anchor title
- e27986b: Reword "Everywhere" search scope to "All docs"
- 758b34d: Move the "Ask <query>" to the bottom of search results if query is not a question
- 0ae7cfd: Improve AI Search branding
- f9f8011: Add alt text support to card covers
- ebbb476: Improve ancestor label in references
- 1b0613e: Tweak language dropdown styling
- 3355960: Fix ToggeableLinkItem display
- c31e564: Fix icon resolution for page group
- 55c0b03: Support translations and generic variants together
- 3876f70: Edgeless Assistant window on `depth: flat` sites
- 683bb8f: Add confirmation to "Copy MCP URL" page action
- 4534ea8: Implement unique title tags for sections & variants
- 6d56df7: Treat pages without visible TOC items as `no-toc`
- 8606f22: Add padding to TOC page group items
- 3676d64: Update search scope UI
- c32d449: Fix OpenAPI path display
- 8ce7322: Add OpenAPI servers selection
- 2c3066e: Improve OAuth2 scopes handling in OpenAPI
- 6b1c9fe: Fix opacity of ScrollContainer buttons on bold themes
- Updated dependencies [b4a021a]
- Updated dependencies [a512c90]
- Updated dependencies [df1966d]
- Updated dependencies [b45feaf]
- Updated dependencies [10995e0]
- Updated dependencies [f9f8011]
- Updated dependencies [10995e0]
- Updated dependencies [8ce7322]
- Updated dependencies [2c3066e]
- @gitbook/react-openapi@1.5.3
- @gitbook/openapi-parser@3.0.5
- @gitbook/react-contentkit@0.7.8
- @gitbook/browser-types@0.1.3
- @gitbook/cache-tags@0.3.4
- @gitbook/react-math@0.6.4
- @gitbook/colors@0.4.3
- @gitbook/embed@0.1.5
- @gitbook/fonts@0.1.3
- @gitbook/icons@0.3.4
- @gitbook/expr@1.2.4
## 0.19.2
### Patch Changes
- 6142d6b: Mark as sideEffects, fix all package bundles
- 75543b1: Update minimum Node version in .npmrc
- 511f1cf: Add scroll margin to Expandable
- Updated dependencies [6142d6b]
- @gitbook/emoji-codepoints@0.2.2
- @gitbook/react-contentkit@0.7.7
- @gitbook/openapi-parser@3.0.4
- @gitbook/browser-types@0.1.2
- @gitbook/react-openapi@1.5.2
- @gitbook/cache-tags@0.3.3
- @gitbook/react-math@0.6.3
- @gitbook/colors@0.4.2
- @gitbook/fonts@0.1.2
- @gitbook/icons@0.3.3
- @gitbook/expr@1.2.3
## 0.19.1 ## 0.19.1
### Patch Changes ### Patch Changes
+5 -10
View File
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://adiblar.gitbook.io', contentBaseURL: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
{ // {
name: 'docs.gradient.network', // name: 'docs.gradient.network',
contentBaseURL: 'https://docs.gradient.network', // contentBaseURL: 'https://docs.gradient.network',
tests: [{ name: 'Home', url: '/' }], // tests: [{ name: 'Home', url: '/' }],
}, // },
// { // {
// name: 'mygate-network.gitbook.io', // name: 'mygate-network.gitbook.io',
// contentBaseURL: 'https://mygate-network.gitbook.io', // contentBaseURL: 'https://mygate-network.gitbook.io',
@@ -172,11 +172,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.realapp.link', contentBaseURL: 'https://docs.realapp.link',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
{
name: 'docs.plaza.finance',
contentBaseURL: 'https://docs.plaza.finance',
tests: [{ name: 'Home', url: '/' }],
},
{ {
name: 'docs.publicai.io', name: 'docs.publicai.io',
contentBaseURL: 'https://docs.publicai.io', contentBaseURL: 'https://docs.publicai.io',
+164 -34
View File
@@ -33,9 +33,35 @@ import {
headerLinks, headerLinks,
runTestCases, runTestCases,
waitForCookiesDialog, waitForCookiesDialog,
waitForCoverImages,
waitForNotFound, waitForNotFound,
} from './util'; } from './util';
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
const overrideAIInitialState = () => {
const greeting = document.querySelector('[data-testid="ai-chat-time-greeting"]');
if (greeting) {
greeting.textContent = 'Good morning';
}
};
const overrideAIResponse = () => {
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
if (userMessage) {
userMessage.textContent = '[Replaced message] Chat message sent by the user';
}
const assistantMessage = document.querySelectorAll(
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
);
assistantMessage.forEach((message) => {
message.innerHTML = '[Replaced message] AI chat response';
});
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
suggestions.forEach((suggestion) => {
suggestion.textContent = 'Follow-up suggestion';
});
};
const searchTestCases: Test[] = [ const searchTestCases: Test[] = [
{ {
name: 'Search - AI Mode: None - Complete flow', name: 'Search - AI Mode: None - Complete flow',
@@ -52,7 +78,9 @@ const searchTestCases: Test[] = [
// Fill search input, expecting search results // Fill search input, expecting search results
await searchInput.fill('gitbook'); await searchInput.fill('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible(); await expect(page.getByTestId('search-results')).toBeVisible({
timeout: 10_000,
});
const pageResults = await page.getByTestId('search-page-result').all(); const pageResults = await page.getByTestId('search-page-result').all();
await expect(pageResults.length).toBeGreaterThanOrEqual(1); await expect(pageResults.length).toBeGreaterThanOrEqual(1);
const pageSectionResults = await page.getByTestId('search-page-section-result').all(); const pageSectionResults = await page.getByTestId('search-page-section-result').all();
@@ -97,9 +125,6 @@ const searchTestCases: Test[] = [
await expect(page.getByTestId('search-results')).toBeVisible(); await expect(page.getByTestId('search-results')).toBeVisible();
}, },
}, },
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Search - AI Mode: Search - Complete flow
// - Search - AI Mode: Search - URL query (Initial)
{ {
name: 'Search - AI Mode: Search - URL query (Results)', name: 'Search - AI Mode: Search - URL query (Results)',
url: `${getCustomizationURL({ url: `${getCustomizationURL({
@@ -107,16 +132,12 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Search, mode: CustomizationAIMode.Search,
}, },
})}&q=gitbook`, })}&q=gitbook`,
screenshot: false,
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-input')).toBeFocused(); await expect(page.getByTestId('search-input')).toBeFocused();
await expect(page.getByTestId('search-input')).toHaveValue('gitbook'); await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
await expect(page.getByTestId('search-results')).toBeVisible(); await expect(page.getByTestId('search-results')).toBeVisible();
}, },
}, },
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
// - Ask - AI Mode: Search - URL query (Ask initial)
// - Ask - AI Mode: Search - URL query (Ask results)
{ {
name: 'Ask - AI Mode: Assistant - Complete flow', name: 'Ask - AI Mode: Assistant - Complete flow',
url: getCustomizationURL({ url: getCustomizationURL({
@@ -124,25 +145,32 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
}), }),
screenshot: false,
run: async (page) => { run: async (page) => {
const searchInput = page.locator('css=[data-testid="search-input"]'); const searchInput = page.locator('css=[data-testid="search-input"]');
// Focus search input, expecting recommended questions // Focus search input, expecting recommended questions
await searchInput.focus(); await searchInput.focus();
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out await expect(page.getByTestId('search-results')).toBeVisible({
// await expect(page.getByTestId('search-results')).toBeVisible(); timeout: 30_000,
// const recommendedQuestions = await page });
// .getByTestId('search-recommended-question') const recommendedQuestions = await page
// .all(); .getByTestId('search-recommended-question')
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions .all();
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
// Fill search input, expecting AI search option // Fill search input, expecting AI search option
await searchInput.fill('What is gitbook?'); await searchInput.fill(AI_PROMPT);
const aiSearchResult = page.getByTestId('search-ask-question'); const aiSearchResult = page.getByTestId('search-ask-question');
await expect(aiSearchResult).toBeVisible(); await expect(aiSearchResult).toBeVisible();
await aiSearchResult.click(); await aiSearchResult.click();
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
});
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIResponse);
}, },
}, },
{ {
@@ -152,11 +180,12 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
}), }),
screenshot: false,
run: async (page) => { run: async (page) => {
await page.keyboard.press('ControlOrMeta+I'); await page.keyboard.press('ControlOrMeta+I');
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
}, },
}, },
{ {
@@ -171,6 +200,8 @@ const searchTestCases: Test[] = [
await page.getByTestId('ai-chat-button').click(); await page.getByTestId('ai-chat-button').click();
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
}, },
}, },
{ {
@@ -180,12 +211,13 @@ const searchTestCases: Test[] = [
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
})}&ask=`, })}&ask=`,
screenshot: false,
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused(); await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?'); await expect(page.getByTestId('search-input')).toBeEmpty();
await expect(page.getByTestId('ai-chat')).toBeVisible(); await expect(page.getByTestId('ai-chat')).toBeVisible();
await expect(page.getByTestId('ai-chat-input')).toBeFocused(); await expect(page.getByTestId('ai-chat-input')).toBeFocused();
// Override text content for visual consistency in screenshots
await page.evaluate(overrideAIInitialState);
}, },
}, },
{ {
@@ -194,17 +226,18 @@ const searchTestCases: Test[] = [
ai: { ai: {
mode: CustomizationAIMode.Assistant, mode: CustomizationAIMode.Assistant,
}, },
})}&ask=What+is+GitBook%3F`, })}&ask=${encodeURIComponent(AI_PROMPT)}`,
screenshot: false,
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-input')).not.toBeFocused(); await expect(page.getByTestId('search-input')).not.toBeFocused();
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?'); await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
await expect(page.getByTestId('ai-chat')).toBeVisible({ await expect(page.getByTestId('ai-chat')).toBeVisible();
timeout: 15_000, await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
timeout: 60_000,
}); });
await expect(page.getByTestId('ai-chat-message').first()).toHaveText( // Override text content for visual consistency in screenshots
'What is GitBook?' await page.evaluate(overrideAIResponse);
);
}, },
}, },
]; ];
@@ -228,6 +261,58 @@ const testCases: TestsCase[] = [
); );
}, },
}, },
{
name: 'Expandable TOC navigation',
url: '',
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Navigation" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Navigation' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// Verify "Navigation" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
{
name: 'Expandable nested TOC navigation',
url: '',
screenshot: false,
run: async (page) => {
await waitForCookiesDialog(page);
// Verify "Spaces" link is not visible initially
const navigationLink = page.getByRole('link', { name: 'Spaces' });
await expect(navigationLink).not.toBeVisible();
// Find and click the chevron element that is next to "Editor" in the TOC
// It is a span inside the link
const editorChevron = page
.getByRole('link', { name: 'Editor' })
.locator('span');
await editorChevron.click();
// At this stage the link should still not be visible
await expect(navigationLink).not.toBeVisible();
// Then we click 'Content Structure' chevron to expand further
const contentStructureChevron = page
.getByRole('link', { name: 'Content Structure' })
.locator('span');
await contentStructureChevron.click();
// Verify "Spaces" link becomes visible after expansion
await expect(navigationLink).toBeVisible();
},
},
...searchTestCases, ...searchTestCases,
{ {
name: 'Not found', name: 'Not found',
@@ -285,6 +370,37 @@ const testCases: TestsCase[] = [
).toBeVisible(); ).toBeVisible();
}, },
}, },
{
name: 'Switch variant with alternate link in metadata',
url: 'rfcs',
run: async (page) => {
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the variant space called 'Multi-Variants' for which
// there is an alternate link in the current (RFC variant) page metadata
await variantSelectionDropdown
.getByRole('menuitem', {
name: 'Multi-Variants',
})
.click();
// It should navigate to the alternate link defined in the metadata (a completely different page)
await page.waitForURL((url) =>
url.pathname.includes('multi-variants/reference/api-reference/pets')
);
// Verify we are on the correct page by checking the h1
await expect(
page.getByRole('heading', { level: 1, name: 'Pets' })
).toBeVisible();
},
},
], ],
}, },
{ {
@@ -727,7 +843,10 @@ const testCases: TestsCase[] = [
url: 'blocks/integrations', url: 'blocks/integrations',
run: async (page) => { run: async (page) => {
await waitForCookiesDialog(page); await waitForCookiesDialog(page);
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame(); const mermaidIframe = page
.locator('iframe[title*="mermaid"]')
.first()
.contentFrame();
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible(); await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible(); await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
}, },
@@ -793,6 +912,11 @@ const testCases: TestsCase[] = [
url: 'blocks/cards', url: 'blocks/cards',
fullPage: true, fullPage: true,
}, },
{
name: 'Updates',
url: 'blocks/updates',
fullPage: true,
},
{ {
name: 'Math', name: 'Math',
url: 'blocks/math', url: 'blocks/math',
@@ -851,7 +975,10 @@ const testCases: TestsCase[] = [
{ {
name: 'With cover', name: 'With cover',
url: 'page-options/page-with-cover', url: 'page-options/page-with-cover',
run: waitForCookiesDialog, run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
}, },
{ {
name: 'With cover for dark mode', name: 'With cover for dark mode',
@@ -866,12 +993,18 @@ const testCases: TestsCase[] = [
{ {
name: 'With hero cover', name: 'With hero cover',
url: 'page-options/page-with-hero-cover', url: 'page-options/page-with-hero-cover',
run: waitForCookiesDialog, run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
}, },
{ {
name: 'With cover and no TOC', name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc', url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog, run: async (page) => {
await waitForCookiesDialog(page);
await waitForCoverImages(page);
},
screenshot: { screenshot: {
waitForTOCScrolling: false, waitForTOCScrolling: false,
}, },
@@ -1454,10 +1587,7 @@ const testCases: TestsCase[] = [
locale, locale,
}, },
}), }),
run: async (page) => { run: waitForCookiesDialog,
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible();
},
})), })),
}, },
{ {
+11 -1
View File
@@ -143,7 +143,10 @@ export const headerLinks: CustomizationHeaderItem[] = [
export async function waitForCookiesDialog(page: Page) { export async function waitForCookiesDialog(page: Page) {
const dialog = page.getByTestId('cookies-dialog'); const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible({
// Cookies dialog may take some times to appear
timeout: 10_000,
});
} }
export async function waitForNotFound(_page: Page, response: Response | null) { export async function waitForNotFound(_page: Page, response: Response | null) {
@@ -151,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response?.status()).toBe(404); expect(response?.status()).toBe(404);
} }
export async function waitForCoverImages(page: Page) {
// Wait for cover images to exist (not the shimmer placeholder)
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
timeout: 10_000,
});
}
/** /**
* Transform test cases into Playwright tests and run it. * Transform test cases into Playwright tests and run it.
*/ */
+4 -2
View File
@@ -7,7 +7,8 @@ export default {
converter: 'edge', converter: 'edge',
proxyExternalRequest: 'fetch', proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default), queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), incrementalCache: () =>
import('./openNext/incrementalCache/server').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}, },
@@ -18,7 +19,8 @@ export default {
converter: 'edge', converter: 'edge',
proxyExternalRequest: 'fetch', proxyExternalRequest: 'fetch',
queue: () => import('./openNext/queue/middleware').then((m) => m.default), queue: () => import('./openNext/queue/middleware').then((m) => m.default),
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), incrementalCache: () =>
import('./openNext/incrementalCache/middleware').then((m) => m.default),
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default), tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
}, },
}, },
@@ -1,15 +1,13 @@
{ {
"main": "default.js", "main": "default.js",
"name": "gitbook-open-v2-server", "name": "gitbook-open-v2-server",
"keep_names": false,
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"observability": {
"enabled": true
},
"vars": { "vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
}, },
@@ -2,14 +2,12 @@
"main": "do.js", "main": "do.js",
"name": "gitbook-open-v2-do", "name": "gitbook-open-v2-do",
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
"global_fetch_strictly_public" "global_fetch_strictly_public"
], ],
"observability": {
"enabled": true
},
"env": { "env": {
"preview": { "preview": {
"vars": { "vars": {
@@ -2,6 +2,7 @@
"main": "middleware.js", "main": "middleware.js",
"name": "gitbook-open-v2", "name": "gitbook-open-v2",
"compatibility_date": "2025-04-14", "compatibility_date": "2025-04-14",
"keep_names": false,
"compatibility_flags": [ "compatibility_flags": [
"nodejs_compat", "nodejs_compat",
"allow_importable_env", "allow_importable_env",
@@ -11,9 +12,6 @@
"directory": "../../.open-next/assets", "directory": "../../.open-next/assets",
"binding": "ASSETS" "binding": "ASSETS"
}, },
"observability": {
"enabled": true
},
"vars": { "vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true" "NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
}, },
@@ -7,8 +7,6 @@ import type {
} from '@opennextjs/aws/types/overrides.js'; } from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare'; import { getCloudflareContext } from '@opennextjs/cloudflare';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types'; import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET'; export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
@@ -23,7 +21,7 @@ export type KeyOptions = {
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional * It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast. * R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
*/ */
class GitbookIncrementalCache implements IncrementalCache { export class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache'; name = 'GitbookIncrementalCache';
async get<CacheType extends CacheEntryType = 'cache'>( async get<CacheType extends CacheEntryType = 'cache'>(
@@ -137,12 +135,3 @@ class GitbookIncrementalCache implements IncrementalCache {
); );
} }
} }
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -0,0 +1,11 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// We can do it because we use our own logic to invalidate the cache
bypassTagCacheOnCacheHit: true,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
@@ -0,0 +1,12 @@
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import { GitbookIncrementalCache } from './incrementalCache';
export default withRegionalCache(new GitbookIncrementalCache(), {
mode: 'long-lived',
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
bypassTagCacheOnCacheHit: false,
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
// We don't want to update the cache entry on every cache hit
shouldLazilyUpdateOnCacheHit: false,
});
+21 -15
View File
@@ -1,12 +1,14 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.19.1", "version": "0.20.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@cloudflare/workers-types": "^4.20251011.0",
"@gitbook/api": "catalog:", "@gitbook/api": "catalog:",
"@gitbook/browser-types": "workspace:*", "@gitbook/browser-types": "workspace:*",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
"@gitbook/embed": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/expr": "workspace:*", "@gitbook/expr": "workspace:*",
"@gitbook/fonts": "workspace:*", "@gitbook/fonts": "workspace:*",
@@ -15,8 +17,9 @@
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@modelcontextprotocol/sdk": "^1.17.5", "@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/cloudflare": "^1.9.2", "@opennextjs/aws": "^3.8.5",
"@opennextjs/cloudflare": "^1.11.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
@@ -28,19 +31,16 @@
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@tusbar/cache-control": "^1.0.2", "@tusbar/cache-control": "^1.0.2",
"ai": "^4.2.2", "ai": "^4.2.2",
"assert-never": "^1.2.1", "assert-never": "catalog:",
"bidc": "catalog:", "bidc": "catalog:",
"bun-types": "^1.1.20", "classnames": "catalog:",
"classnames": "^2.5.1",
"direction": "^2.0.1", "direction": "^2.0.1",
"event-iterator": "^2.0.0", "event-iterator": "^2.0.0",
"framer-motion": "^10.16.14", "feed": "^5.1.0",
"image-size": "^2.0.2", "image-size": "^2.0.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mcp-handler": "^1.0.2", "mcp-handler": "^1.0.2",
"mdast-util-from-markdown": "^2.0.2", "mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1", "mdast-util-frontmatter": "^2.0.1",
@@ -49,7 +49,7 @@
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"micromark-extension-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"motion": "^12.23.12", "motion": "^12.23.24",
"next": "15.4.0", "next": "15.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
@@ -58,6 +58,8 @@
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.3", "p-map": "^7.0.3",
"quick-lru": "^7.0.1", "quick-lru": "^7.0.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
@@ -73,7 +75,7 @@
"unist-util-remove": "^4.0.0", "unist-util-remove": "^4.0.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"url-join": "^5.0.0", "url-join": "^5.0.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "catalog:",
"warn-once": "^0.1.1", "warn-once": "^0.1.1",
"zod": "^3", "zod": "^3",
"zustand": "^5.0.3" "zustand": "^5.0.3"
@@ -81,6 +83,7 @@
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^5.0.9", "@argos-ci/playwright": "^5.0.9",
"@playwright/test": "^1.54.2", "@playwright/test": "^1.54.2",
"@scalar/api-client-react": "catalog:",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5", "@types/jsontoxml": "^1.0.5",
@@ -89,9 +92,10 @@
"@types/node": "^20", "@types/node": "^20",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4", "@types/parse-cache-control": "^1.0.4",
"@types/react": "18.3.13", "@types/react": "catalog:",
"@types/react-dom": "18.3.1", "@types/react-dom": "catalog:",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
"bun-types": "catalog:",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -99,8 +103,10 @@
"stylelint": "^16.16.0", "stylelint": "^16.16.0",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"ts-essentials": "^10.0.1", "ts-essentials": "^10.0.1",
"typescript": "^5.5.3", "typescript": "catalog:",
"vercel": "^39.3.0" "vercel": "^39.3.0",
"wrangler": "^4.43.0",
"rss-parser": "^3.13.0"
}, },
"scripts": { "scripts": {
"generate": "./scripts/generate.sh", "generate": "./scripts/generate.sh",
@@ -22,7 +22,11 @@ export default async function SiteDynamicLayout({
const withTracking = shouldTrackEvents(await headers()); const withTracking = shouldTrackEvents(await headers());
return ( return (
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}> <CustomizationRootLayout
className="site-background"
forcedTheme={forcedTheme}
context={context}
>
<SiteLayout <SiteLayout
context={context} context={context}
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
@@ -1,16 +1,7 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable'; import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
type PageProps = { export default async function Page() {
params: Promise<RouteLayoutParams>; return <EmbeddableAssistantPage />;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
return <EmbeddableAssistantPage context={context} />;
} }
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
const withTracking = shouldTrackEvents(); const withTracking = shouldTrackEvents();
return ( return (
<CustomizationRootLayout context={context}> <CustomizationRootLayout className="site-background" context={context}>
<SiteLayout <SiteLayout
context={context} context={context}
withTracking={withTracking} withTracking={withTracking}
@@ -1,16 +1,7 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable'; import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
type PageProps = { export default async function Page() {
params: Promise<RouteLayoutParams>; return <EmbeddableAssistantPage />;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
return <EmbeddableAssistantPage context={context} />;
} }
@@ -0,0 +1,12 @@
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
import { servePageRSS } from '@/routes/rss';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageRSS(context, pathname);
}
@@ -16,14 +16,14 @@ export function AIMessageView(
) { ) {
const { message, context, withToolCalls = true, withLinkPreviews = true } = props; const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
return ( return message.steps.length > 0 ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{message.steps.map((step, index) => { {message.steps.map((step, index) => {
return ( return (
<div <div
key={index} key={index}
className={tcls( className={tcls(
'flex animate-fade-in-slow flex-col gap-2', 'flex flex-col gap-2',
step.content.nodes.length > 0 ? 'has-content' : '' step.content.nodes.length > 0 ? 'has-content' : ''
)} )}
> >
@@ -35,7 +35,7 @@ export function AIMessageView(
wrapBlocksInSuspense: false, wrapBlocksInSuspense: false,
withLinkPreviews, withLinkPreviews,
}} }}
style="mt-2 space-y-4 empty:hidden" style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
/> />
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? ( {withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
@@ -45,5 +45,5 @@ export function AIMessageView(
); );
})} })}
</div> </div>
); ) : null;
} }
@@ -39,7 +39,7 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
const { toolCall, context } = props; const { toolCall, context } = props;
return ( return (
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle"> <div className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle">
<Icon <Icon
icon={getIconForToolCall(toolCall)} icon={getIconForToolCall(toolCall)}
className="mt-1 size-3 shrink-0 text-tint-subtle/8" className="mt-1 size-3 shrink-0 text-tint-subtle/8"
@@ -160,7 +160,7 @@ async function DescriptionForSearchToolCall(props: {
const hasResults = toolCall.results.length > 0; const hasResults = toolCall.results.length > 0;
return ( return (
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}> <details className="-ml-5 group flex w-full flex-col">
<summary <summary
className={tcls( className={tcls(
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden', '-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
@@ -187,7 +187,7 @@ async function DescriptionForSearchToolCall(props: {
) : null} ) : null}
</summary> </summary>
{hasResults ? ( {hasResults ? (
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11"> <div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
<ol className="space-y-1"> <ol className="space-y-1">
{searchResultsWithHrefs.map((result, index) => ( {searchResultsWithHrefs.map((result, index) => (
<li <li
+3 -12
View File
@@ -1,14 +1,14 @@
'use client'; 'use client';
import { CustomizationAIMode } from '@gitbook/api'; import { CustomizationAIMode } from '@gitbook/api';
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import * as React from 'react'; import * as React from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { tString, useLanguage } from '@/intl/client'; import { tString, useLanguage } from '@/intl/client';
import type { GitBookAssistant } from '@gitbook/browser-types'; import type { GitBookAssistant } from '@gitbook/browser-types';
import { useAIChatController, useAIChatState } from '.'; import { useAIChatController, useAIChatState } from '.';
import { AIChatIcon, getAIChatName } from '../AIChat'; import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations'; import { useIntegrationAssistants } from '../Integrations';
import { useSearch } from '../Search/useSearch'; import { useSearch } from '../Search/useSearch';
@@ -104,16 +104,7 @@ export function useAI(): AIContext {
assistants.push({ assistants.push({
id: 'gitbook-ai-search', id: 'gitbook-ai-search',
label: tString(language, 'ai_chat_context_badge'), label: tString(language, 'ai_chat_context_badge'),
icon: ( icon: <AISearchIcon />,
<div className="relative">
<Icon icon="search" className="size-4" />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
className="absolute top-[2.5px] left-[2.6px] size-2"
/>
</div>
),
open: (query?: string) => { open: (query?: string) => {
if (query) { if (query) {
setSearchState((prev) => setSearchState((prev) =>
@@ -2,12 +2,14 @@
import { t, tString, useLanguage } from '@/intl/client'; import { t, tString, useLanguage } from '@/intl/client';
import type { TranslationLanguage } from '@/intl/translations'; import type { TranslationLanguage } from '@/intl/translations';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import React from 'react'; import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { import {
type AIChatController, type AIChatController,
type AIChatState, type AIChatState,
useAI,
useAIChatController, useAIChatController,
useAIChatState, useAIChatState,
} from '../AI'; } from '../AI';
@@ -23,15 +25,15 @@ import {
import { useTrackEvent } from '../Insights'; import { useTrackEvent } from '../Insights';
import { useNow } from '../hooks'; import { useNow } from '../hooks';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { AIChatControlButton } from './AIChatControlButton'; import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon'; import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput'; import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages'; import { AIChatMessages } from './AIChatMessages';
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions'; import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
export function AIChat(props: { trademark: boolean }) { export function AIChat() {
const { trademark } = props; const { config } = useAI();
const language = useLanguage(); const language = useLanguage();
const chat = useAIChatState(); const chat = useAIChatState();
const chatController = useAIChatController(); const chatController = useAIChatController();
@@ -65,21 +67,22 @@ export function AIChat(props: { trademark: boolean }) {
} }
}, [chat.opened, trackEvent]); }, [chat.opened, trackEvent]);
if (!chat.opened) {
return null;
}
return ( return (
<div <div
data-testid="ai-chat" data-testid="ai-chat"
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96" className={tcls(
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96',
chat.opened
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
)}
> >
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle"> <EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
<EmbeddableFrameHeader> <EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} /> <AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameHeaderMain> <EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle> <EmbeddableFrameTitle>
{getAIChatName(language, trademark)} {getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle> </EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} /> <AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain> </EmbeddableFrameHeaderMain>
@@ -96,7 +99,7 @@ export function AIChat(props: { trademark: boolean }) {
</EmbeddableFrameButtons> </EmbeddableFrameButtons>
</EmbeddableFrameHeader> </EmbeddableFrameHeader>
<EmbeddableFrameBody> <EmbeddableFrameBody>
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} /> <AIChatBody chatController={chatController} chat={chat} />
</EmbeddableFrameBody> </EmbeddableFrameBody>
</EmbeddableFrame> </EmbeddableFrame>
</div> </div>
@@ -143,10 +146,33 @@ export function AIChatSubtitle(props: {
const language = useLanguage(); const language = useLanguage();
return ( return (
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}> <EmbeddableFrameSubtitle
{chat.messages[chat.messages.length - 1]?.content className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
? tString(language, 'ai_chat_working') >
: tString(language, 'ai_chat_thinking')} <span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-in-slow'
: 'hidden'
: 'animate-blur-out-slow'
)}
>
{t(language, 'ai_chat_working')}
</span>
<span
className={tcls(
'absolute left-0',
chat.loading
? chat.messages[chat.messages.length - 1]?.content
? 'animate-blur-out-slow'
: 'animate-blur-in-slow'
: 'hidden'
)}
>
{t(language, 'ai_chat_thinking')}
</span>
</EmbeddableFrameSubtitle> </EmbeddableFrameSubtitle>
); );
} }
@@ -157,20 +183,13 @@ export function AIChatSubtitle(props: {
export function AIChatBody(props: { export function AIChatBody(props: {
chatController: AIChatController; chatController: AIChatController;
chat: AIChatState; chat: AIChatState;
trademark: boolean;
welcomeMessage?: string; welcomeMessage?: string;
suggestions?: string[]; suggestions?: string[];
}) { }) {
const { chatController, chat, trademark, suggestions } = props; const { chatController, chat, suggestions } = props;
const { trademark } = useAI().config;
const [input, setInput] = React.useState(''); const [input, setInput] = React.useState('');
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
// Ref for the last user message element
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLDivElement>(null);
const [inputHeight, setInputHeight] = React.useState(0);
const language = useLanguage(); const language = useLanguage();
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
@@ -184,67 +203,43 @@ export function AIChatBody(props: {
return tString(language, 'ai_chat_assistant_greeting_evening'); return tString(language, 'ai_chat_assistant_greeting_evening');
}, [now, language]); }, [now, language]);
// Auto-scroll to the latest user message when messages change
React.useEffect(() => {
if (chat.messages.length > 0 && lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, [chat.messages.length]);
React.useEffect(() => {
const timeout = setTimeout(() => {
if (lastUserMessageRef.current) {
lastUserMessageRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, 100);
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
setInputHeight(entry.contentRect.height + 32);
});
});
if (inputRef.current) {
observer.observe(inputRef.current);
}
return () => {
observer.disconnect();
clearTimeout(timeout);
};
}, []);
return ( return (
<> <>
<div <ScrollContainer
ref={scrollContainerRef} className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4" contentClassName="p-4 gutter-stable flex flex-col gap-4"
style={{ orientation="vertical"
paddingBottom: `${inputHeight}px`, fadeEdges={['leading']}
}} active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
> >
{isEmpty ? ( {isEmpty ? (
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4"> <div className="flex grow flex-col">
<div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle"> <div className="my-auto flex flex-row items-center gap-4 pb-6 [@container(min-height:400px)]:flex-col">
<AIChatIcon <div
state="intro" className="flex size-16 shrink-0 animate-scale-in items-center justify-center rounded-full bg-primary-solid/1 [@container(min-height:400px)]:size-32"
trademark={trademark} style={{ animationDelay: '.3s' }}
className="size-16 animate-[present_500ms_200ms_both]" >
/> <AIChatIcon
</div> state="intro"
<div className="animate-[fadeIn_500ms_400ms_both]"> trademark={trademark}
<h5 className=" text-center font-bold text-lg text-tint-strong"> className="size-8 text-primary [@container(min-height:400px)]:size-16"
{timeGreeting} />
</h5> </div>
<p className="text-center text-tint"> <div className="flex flex-col items-start [@container(min-height:400px)]:items-center">
{t(language, 'ai_chat_assistant_description')} <h5
</p> className="animate-blur-in-slow font-bold text-lg text-tint-strong [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.5s' }}
data-testid="ai-chat-time-greeting"
>
{timeGreeting}
</h5>
<p
className="animate-blur-in-slow text-tint [@container(min-height:400px)]:text-center"
style={{ animationDelay: '.6s' }}
>
{t(language, 'ai_chat_assistant_description')}
</p>
</div>
</div> </div>
{!chat.error ? ( {!chat.error ? (
<AIChatSuggestedQuestions <AIChatSuggestedQuestions
@@ -254,17 +249,11 @@ export function AIChatBody(props: {
) : null} ) : null}
</div> </div>
) : ( ) : (
<AIChatMessages <AIChatMessages chat={chat} chatController={chatController} />
chat={chat}
chatController={chatController}
lastUserMessageRef={lastUserMessageRef}
/>
)} )}
</div> </ScrollContainer>
<div
ref={inputRef} <div className="flex flex-col gap-2 px-4 pb-4">
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
>
{/* Display an error banner when something went wrong. */} {/* Display an error banner when something went wrong. */}
{chat.error ? <AIChatError chatController={chatController} /> : null} {chat.error ? <AIChatError chatController={chatController} /> : null}
@@ -23,7 +23,7 @@ export function AIChatButton(props: {
iconOnly={!showLabel} iconOnly={!showLabel}
size="medium" size="medium"
variant="header" variant="header"
className="h-9 px-2.5 max-md:[&_.button-content]:hidden" className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden"
label={ label={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{t(language, 'ai_chat_ask', assistant.label)} {t(language, 'ai_chat_ask', assistant.label)}
@@ -1,5 +1,5 @@
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon, IconStyle } from '@gitbook/icons';
import type React from 'react'; import type React from 'react';
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> { interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
@@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
trademark?: boolean; trademark?: boolean;
} }
export const AIChatIcon = ({ export function AIChatIcon({
className = 'size-4', className = 'size-4',
size, size,
trademark = true, trademark = true,
state = 'default', state = 'default',
...props ...props
}: AIChatIconProps) => { }: AIChatIconProps) {
if (!trademark) { if (!trademark) {
return ( return (
<Icon <Icon
icon="sparkle" icon="sparkle"
{...props}
className={tcls( className={tcls(
className, className,
(state === 'thinking' || state === 'working') && (state === 'thinking' || state === 'working') &&
@@ -163,4 +164,36 @@ export const AIChatIcon = ({
</g> </g>
</svg> </svg>
); );
}; }
export function AISearchIcon({
className = 'size-4',
state = 'default',
}: Pick<AIChatIconProps, 'className' | 'state'>) {
return (
<div
className={tcls(
'relative',
state === 'intro' && 'animate-[fadeIn_1s_both,orbit_1s_cubic-bezier(0.16,1,0.3,1)]',
state === 'thinking' || state === 'working'
? 'animate-[fadeIn_1s_both,orbit_1s_ease-out,orbit_2s_1s_infinite_forwards_linear]'
: ''
)}
>
<Icon icon="search" className={className} />
<Icon
icon="sparkle"
iconStyle={IconStyle.Solid}
className={tcls(
'absolute top-[15.7%] left-[15.6%] size-[50%]',
state === 'thinking' || state === 'working'
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
: '',
state === 'intro'
? 'animate-[spin_2s_.5s_forwards_cubic-bezier(0.16,1,0.3,1)]'
: ''
)}
/>
</div>
);
}
@@ -3,6 +3,7 @@ import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useAIChatState } from '../AI/useAIChat';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives'; import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut'; import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
@@ -19,6 +20,7 @@ export function AIChatInput(props: {
const { value, onChange, onSubmit, disabled, loading } = props; const { value, onChange, onSubmit, disabled, loading } = props;
const language = useLanguage(); const language = useLanguage();
const chat = useAIChatState();
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -32,7 +34,7 @@ export function AIChatInput(props: {
}; };
useEffect(() => { useEffect(() => {
if (!disabled && !loading) { if (chat.opened && !disabled && !loading) {
// Add a small delay to ensure the input is rendered before focusing // Add a small delay to ensure the input is rendered before focusing
// This fixes inconsistent focus behaviour across browsers // This fixes inconsistent focus behaviour across browsers
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -41,7 +43,7 @@ export function AIChatInput(props: {
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} }
}, [disabled, loading]); }, [disabled, loading, chat.opened]);
useHotkeys( useHotkeys(
'mod+i', 'mod+i',
@@ -55,7 +57,7 @@ export function AIChatInput(props: {
); );
return ( return (
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1"> <div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex animate-blur-in-slow flex-col overflow-hidden circular-corners:rounded-3xl rounded-corners:rounded-xl bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
<textarea <textarea
ref={inputRef} ref={inputRef}
disabled={disabled || loading} disabled={disabled || loading}
@@ -106,7 +108,7 @@ export function AIChatInput(props: {
</div> </div>
) : null} ) : null}
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2"> <div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
<HoverCardRoot> <HoverCardRoot openDelay={500}>
<HoverCard <HoverCard
className="max-w-xs bg-tint p-2 text-sm text-tint" className="max-w-xs bg-tint p-2 text-sm text-tint"
arrow={{ className: 'fill-tint-3' }} arrow={{ className: 'fill-tint-3' }}
@@ -2,7 +2,6 @@ import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate'; import { tString } from '@/intl/translate';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AIMessageRole } from '@gitbook/api'; import { AIMessageRole } from '@gitbook/api';
import type React from 'react';
import type { AIChatController, AIChatState } from '../AI'; import type { AIChatController, AIChatState } from '../AI';
import { AIChatToolConfirmations } from './AIChatToolConfirmations'; import { AIChatToolConfirmations } from './AIChatToolConfirmations';
import { AIResponseFeedback } from './AIResponseFeedback'; import { AIResponseFeedback } from './AIResponseFeedback';
@@ -11,87 +10,133 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
export function AIChatMessages(props: { export function AIChatMessages(props: {
chat: AIChatState; chat: AIChatState;
chatController: AIChatController; chatController: AIChatController;
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
}) { }) {
const { chat, chatController, lastUserMessageRef } = props; const { chat, chatController } = props;
return ( // Group messages: user messages start a new group, all following messages until next user message belong to that group
<> type Message = (typeof chat.messages)[0];
{chat.messages.map((message, index) => { type MessageGroup = { message: Message; originalIndex: number };
const isLastMessage = index === chat.messages.length - 1; const messageGroups: Array<Array<MessageGroup>> = [];
const isLastUserMessage = let currentGroup: Array<MessageGroup> = [];
message.role === AIMessageRole.User &&
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
return ( chat.messages.forEach((message, index) => {
<div if (message.role === AIMessageRole.User) {
ref={isLastUserMessage ? lastUserMessageRef : undefined} // Start a new group
data-testid="ai-chat-message" if (currentGroup.length > 0) {
className={tcls( messageGroups.push(currentGroup);
message.content ? 'animate-fade-in-slow' : '', }
'shrink-0', currentGroup = [{ message, originalIndex: index }];
'last:min-h-[calc(100%-5rem)]', } else {
'scroll-mt-36', // Add to current group
'lg:scroll-mt-0', currentGroup.push({ message, originalIndex: index });
'flex flex-col gap-6', }
'break-words', });
'group/message',
message.role === AIMessageRole.User
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'text-tint-strong'
)}
style={{
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
}}
key={index}
>
{message.content ? message.content : null}
{isLastMessage && chat.loading ? ( // Add the last group if it exists
<div className="flex w-full animate-fade-in-slow flex-col gap-2"> if (currentGroup.length > 0) {
{!message.content ? <HoldMessage /> : null} messageGroups.push(currentGroup);
<LoadingSkeleton /> }
</div>
) : null}
{isLastMessage ? ( return messageGroups.map((group, groupIndex) => {
<> const isLastGroup = group === messageGroups[messageGroups.length - 1];
{!chat.loading && return (
!chat.error && <div
chat.query && key={groupIndex}
chat.responseId && id={`message-group-${groupIndex}`}
chat.pendingTools.length === 0 ? ( className={tcls(
<AIResponseFeedback 'flex flex-col gap-2 pt-2',
responseId={chat.responseId} isLastGroup ? 'shrink-0 basis-full' : '',
query={chat.query}
className="-ml-1 -mt-4" 'transition-discrete'
)}
style={{ animationDelay: '.2s' }}
>
{group.map(({ message, originalIndex }) => {
const isLastMessage = originalIndex === chat.messages.length - 1;
return (
<div
key={originalIndex}
data-testid={
message.role === AIMessageRole.User
? 'ai-chat-message-user'
: 'ai-chat-message-assistant'
}
id={`message-${originalIndex}`}
className={tcls(
'flex flex-col gap-6',
'break-words',
'group/message',
'animate-blur-in-slow',
isLastMessage ? 'basis-full' : '',
message.role === AIMessageRole.User
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
: 'origin-top-left text-tint-strong'
)}
style={{
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
}}
>
{message.content}
{isLastMessage && message.role === AIMessageRole.Assistant ? (
<div
className={tcls(
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
chat.loading ? '' : 'hidden opacity-0'
)}
>
<HoldMessage className={message.content ? 'hidden' : ''} />
<LoadingSkeleton />
</div>
) : null}
{isLastMessage ? (
<>
{!chat.loading &&
!chat.error &&
chat.query &&
chat.responseId &&
chat.pendingTools.length === 0 ? (
<AIResponseFeedback
responseId={chat.responseId}
query={chat.query}
className="-ml-1 -mt-4"
/>
) : null}
<AIChatToolConfirmations chat={chat} />
<AIChatFollowupSuggestions
chat={chat}
chatController={chatController}
/> />
) : null} </>
<AIChatToolConfirmations chat={chat} /> ) : null}
<AIChatFollowupSuggestions </div>
chat={chat} );
chatController={chatController} })}
/> </div>
</> );
) : null} });
</div>
);
})}
</>
);
} }
function HoldMessage() { export function HoldMessage({
breakLines = false,
className,
}: { breakLines?: boolean; className?: string }) {
const language = useLanguage(); const language = useLanguage();
return ( return (
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle"> <div
className={tcls(
'animate-[heightIn_.5s_5s_ease_both] overflow-hidden py-2 text-tint-subtle [&.hidden]:animate-[heightOut_1s_ease-in_both]',
className
)}
>
{tString(language, 'ai_chat_hold_message_1') {tString(language, 'ai_chat_hold_message_1')
.split(' ') .split(' ')
.map((word, index) => ( .map((word, index) => (
<span <span
key={index} key={index}
className="animate-fade-in-slow" className="animate-blur-in-slow"
style={{ style={{
animationDelay: `${5000 + index * 200}ms`, animationDelay: `${5000 + index * 200}ms`,
}} }}
@@ -99,12 +144,13 @@ function HoldMessage() {
{word}{' '} {word}{' '}
</span> </span>
))} ))}
{breakLines ? <br /> : null}
{tString(language, 'ai_chat_hold_message_2') {tString(language, 'ai_chat_hold_message_2')
.split(' ') .split(' ')
.map((word, index) => ( .map((word, index) => (
<span <span
key={index} key={index}
className="animate-fade-in-slow" className="animate-blur-in-slow"
style={{ style={{
animationDelay: `${10000 + index * 200}ms`, animationDelay: `${10000 + index * 200}ms`,
}} }}
@@ -122,9 +168,9 @@ function LoadingSkeleton() {
{Array.from({ length: 7 }).map((_, index) => ( {Array.from({ length: 7 }).map((_, index) => (
<div <div
key={index} key={index}
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4" className="h-4 animate-[blurIn_500ms_ease-out_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2"
style={{ style={{
width: `calc(${(index % 4) * 20 + 10}% - 4px)`, width: `calc(${(4 - (index % 4)) * 8 + 14}% - 4px)`,
animationDelay: `${index * 0.1}s`, animationDelay: `${index * 0.1}s`,
}} }}
/> />
@@ -17,15 +17,15 @@ export default function AIChatSuggestedQuestions(props: {
} = props; } = props;
return ( return (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-start gap-2 self-start">
{suggestions.map((question, index) => ( {suggestions.map((question, index) => (
<Button <Button
key={question} key={question}
variant="secondary" variant="blank"
size="medium" size="medium"
className="max-w-full animate-[present_500ms_both] whitespace-normal" className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 px-3 py-1.5 hover:bg-primary-hover"
style={{ style={{
animationDelay: `${800 + index * 100}ms`, animationDelay: `${1000 + index * 100}ms`,
}} }}
onClick={() => chatController.postMessage({ message: question })} onClick={() => chatController.postMessage({ message: question })}
> >
@@ -29,7 +29,7 @@ export function AIChatToolConfirmations(props: {
); );
return ( return (
<div className="flex w-full flex-wrap justify-end gap-2"> <div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
{chat.pendingTools.map((tool, index) => ( {chat.pendingTools.map((tool, index) => (
<div <div
className="flex w-full animate-present-slow flex-col gap-1" className="flex w-full animate-present-slow flex-col gap-1"
@@ -24,7 +24,9 @@ export function AIResponseFeedback(props: {
}; };
return ( return (
<div className={tcls('flex h-fit items-center', className)}> <div
className={tcls('flex h-fit animate-[heightIn_.5s_ease_both] items-center', className)}
>
<Button <Button
icon="thumbs-up" icon="thumbs-up"
iconOnly iconOnly
@@ -35,7 +37,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0' rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
)} )}
size="medium" size="medium"
style={{ animationDuration: '.5s' }} style={{ animationDelay: '.5s', animationDuration: '.5s' }}
onClick={() => handleRating(1)} onClick={() => handleRating(1)}
disabled={rating !== null} disabled={rating !== null}
active={rating === 1} active={rating === 1}
@@ -51,7 +53,7 @@ export function AIResponseFeedback(props: {
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0' rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
)} )}
size="medium" size="medium"
style={{ animationDelay: '.2s', animationDuration: '.5s' }} style={{ animationDelay: '.7s', animationDuration: '.5s' }}
onClick={() => handleRating(-1)} onClick={() => handleRating(-1)}
disabled={rating !== null} disabled={rating !== null}
active={rating === -1} active={rating === -1}
@@ -59,8 +61,8 @@ export function AIResponseFeedback(props: {
/> />
{rating !== null ? ( {rating !== null ? (
<span <span
className="ml-2 animate-fade-in-slow text-tint-subtle" className="ml-2 animate-blur-in-slow text-tint-subtle"
style={{ animationDelay: '.3s' }} style={{ animationDelay: '.5s' }}
> >
{t(language, 'was_this_helpful_thank_you')} {t(language, 'was_this_helpful_thank_you')}
</span> </span>
@@ -15,22 +15,28 @@ export function AIChatFollowupSuggestions(props: {
} }
return ( return (
<div className="mt-auto flex w-full flex-wrap justify-end gap-2"> <div className="flex grow flex-col">
{chat.followUpSuggestions.map((suggestion, index) => ( <div
<Button className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
key={index} data-testid="ai-chat-followup-suggestions"
onClick={() => { >
chatController.postMessage({ message: suggestion }); {chat.followUpSuggestions.map((suggestion, index) => (
}} <Button
label={suggestion} data-testid="ai-chat-followup-suggestion"
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle" key={index}
size="medium" onClick={() => {
variant="blank" chatController.postMessage({ message: suggestion });
style={{ }}
animationDelay: `${250 + Math.min(index * 50, 150)}ms`, label={suggestion}
}} className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 px-3 py-1.5 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
/> size="medium"
))} variant="blank"
style={{
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
}}
/>
))}
</div>
</div> </div>
); );
} }
@@ -167,10 +167,10 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
icon="comment" icon="comment"
/> />
{/* Open production site */} {/* Open published/live site */}
{site.urls.published ? ( {site.urls.published ? (
<ToolbarButton <ToolbarButton
title="Open production site" title="Open live site"
href={getToolbarHref({ href={getToolbarHref({
href: site.urls.published, href: site.urls.published,
siteId: site.id, siteId: site.id,
@@ -249,7 +249,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
/> />
{site.urls.published ? ( {site.urls.published ? (
<ToolbarButton <ToolbarButton
title="Open production site" title="Open live site"
href={getToolbarHref({ href={getToolbarHref({
href: site.urls.published, href: site.urls.published,
siteId: site.id, siteId: site.id,
@@ -35,6 +35,7 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
} }
setOpen(false); setOpen(false);
}; };
// @ts-expect-error wrong type for ref
useOnClickOutside(ref, handleClickOutsideArcMenu); useOnClickOutside(ref, handleClickOutsideArcMenu);
// Close arc menu on scroll // Close arc menu on scroll
@@ -6,7 +6,7 @@ import {
useReducedMotion, useReducedMotion,
useSpring, useSpring,
} from 'motion/react'; } from 'motion/react';
import React from 'react'; import React, { isValidElement } from 'react';
import { AnimatedLogo } from './AnimatedLogo'; import { AnimatedLogo } from './AnimatedLogo';
import { useToolbarControls } from './ToolbarControlsContext'; import { useToolbarControls } from './ToolbarControlsContext';
@@ -291,6 +291,10 @@ function ToolbarButtonWrapper(props: {
x: springX, x: springX,
}; };
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
return null;
}
return React.cloneElement(child, { return React.cloneElement(child, {
motionValues, motionValues,
}); });
@@ -136,7 +136,7 @@ const calculateSpacing = (
export function useMagnificationEffect(props: { export function useMagnificationEffect(props: {
childrenCount: number; childrenCount: number;
containerRef: React.RefObject<HTMLElement>; containerRef: React.RefObject<HTMLElement | null>;
config?: MagnificationConfig; config?: MagnificationConfig;
}) { }) {
const { childrenCount, containerRef, config } = props; const { childrenCount, containerRef, config } = props;
@@ -27,7 +27,7 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
ancestorBlocks={[]} ancestorBlocks={[]}
context={context} context={context}
nodes={fragment.nodes} nodes={fragment.nodes}
style={['space-y-4']} style={['contents']}
/> />
} }
> >
@@ -1,84 +1,30 @@
'use client'; 'use client';
import * as Popover from '@radix-ui/react-popover';
import type React from 'react'; import type React from 'react';
import { tString, useLanguage } from '@/intl/client'; import { Tooltip } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) { export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
const { children, body } = props; const { children, body } = props;
const language = useLanguage();
return ( return (
<Popover.Root> <Tooltip
<Popover.Trigger asChild> label={body}
<button contentProps={{
data-testid="annotation-button" role: 'definition',
aria-label={tString(language, 'annotation_button_label')} }}
className={tcls( className="bg-tint-base px-4 py-3 text-sm text-tint-strong shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1"
'decoration-dotted', arrow={true}
'decoration-1', arrowProps={{ className: 'fill-tint-1' }}
'underline', >
'underline-offset-2' <dfn
)} data-testid="annotation-button"
> className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
{children} // biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
</button> tabIndex={0}
</Popover.Trigger> >
<Popover.Portal> {children}
<Popover.Content </dfn>
className={tcls( </Tooltip>
'text-sm',
'max-w-[280px]',
'bg-tint',
'ring-1',
'ring-tint',
'rounded-sm',
'shadow-1xs',
'shadow-tint-12/1',
'dark:shadow-tint-1/2',
'p-3',
'[&_p]:leading-snug',
'-outline-offset-2',
'outline-2',
'outline-primary/8',
'z-20'
)}
sideOffset={4}
>
{body}
<Popover.Arrow asChild>
<svg
viewBox="0 0 8 5"
className={tcls(
'relative',
'z-2',
'fill-tint-3', // Same as bg-tint
'stroke-tint-7', // Same as ring-tint
'[paint-order:stroke_fill]'
)}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clipAnnotation)">
<path
d="M0 0L4 4L8 0"
strokeWidth="2"
strokeLinecap="round"
stroke="inherit"
fill="inherit"
/>
</g>
<defs>
<clipPath id="clipAnnotation">
<rect width="8" height="5" fill="white" />
</clipPath>
</defs>
</svg>
</Popover.Arrow>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
); );
} }
@@ -6,6 +6,7 @@ import {
SkeletonImage, SkeletonImage,
SkeletonParagraph, SkeletonParagraph,
SkeletonSmall, SkeletonSmall,
SkeletonUpdate,
} from '@/components/primitives'; } from '@/components/primitives';
import type { ClassValue } from '@/lib/tailwind'; import type { ClassValue } from '@/lib/tailwind';
@@ -34,6 +35,8 @@ import { Stepper } from './Stepper';
import { StepperStep } from './StepperStep'; import { StepperStep } from './StepperStep';
import { Table } from './Table'; import { Table } from './Table';
import { Tabs } from './Tabs'; import { Tabs } from './Tabs';
import { Update } from './Update';
import { Updates } from './Updates';
export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps { export interface BlockProps<Block extends DocumentBlock> extends DocumentContextProps {
block: Block; block: Block;
@@ -105,6 +108,10 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <Stepper {...props} block={block} />; return <Stepper {...props} block={block} />;
case 'stepper-step': case 'stepper-step':
return <StepperStep {...props} block={block} />; return <StepperStep {...props} block={block} />;
case 'updates':
return <Updates {...props} block={block} />;
case 'update':
return <Update {...props} block={block} />;
case 'if': case 'if':
// If block should be processed by the API. // If block should be processed by the API.
return null; return null;
@@ -165,10 +172,13 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'images': case 'images':
case 'drawing': case 'drawing':
return <SkeletonImage id={id} style={style} />; return <SkeletonImage id={id} style={style} />;
case 'updates':
return <SkeletonUpdate id={id} style={style} />;
case 'image': case 'image':
case 'code-line': case 'code-line':
case 'tabs-item': case 'tabs-item':
case 'column': case 'column':
case 'update':
throw new Error(`Blocks (${block.type}) should be directly rendered by parent`); throw new Error(`Blocks (${block.type}) should be directly rendered by parent`);
default: default:
return nullIfNever(block); return nullIfNever(block);
@@ -1,9 +1,18 @@
import { type DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api'; import {
type ContentRef,
type DocumentBlockContentRef,
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { Card } from '@/components/primitives'; import { Card, type CardProps } from '@/components/primitives';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references'; import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
} from '@/lib/references';
import type { BlockProps } from './Block'; import type { BlockProps } from './Block';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) { export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
const { block, context, style } = props; const { block, context, style } = props;
@@ -16,51 +25,45 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
: null; : null;
if (!resolved) { if (!resolved) {
return null; const fallback = resolveContentRefFallback(block.data.ref);
if (!fallback) {
return null;
}
return (
<NotFoundRefHoverCard context={context}>
<BlockContentRefCard
contentRef={block.data.ref}
resolved={fallback}
style={style}
/>
</NotFoundRefHoverCard>
);
} }
const isContentInOtherSpace = return <BlockContentRefCard contentRef={block.data.ref} resolved={resolved} style={style} />;
context.contentContext?.space && }
'space' in block.data.ref &&
context.contentContext.space.id !== block.data.ref.space;
const kind = block?.data?.ref?.kind;
if ((resolved.active && kind === 'space') || isContentInOtherSpace) {
return <SpaceRefCard {...props} resolved={resolved} />;
}
function BlockContentRefCard(
props: {
resolved: ResolvedContentRef;
contentRef: ContentRef;
} & Omit<CardProps, 'href' | 'title'>
) {
const { ref, resolved, contentRef, ...rest } = props;
return ( return (
<Card <Card
ref={ref}
leadingIcon={resolved.icon ? resolved.icon : null} leadingIcon={resolved.icon ? resolved.icon : null}
href={resolved.href} href={resolved.href}
title={resolved.text} title={resolved.text}
style={style}
insights={{ insights={{
type: 'link_click', type: 'link_click',
link: { link: {
target: block.data.ref, target: contentRef,
position: SiteInsightsLinkPosition.Content, position: SiteInsightsLinkPosition.Content,
}, },
}} }}
/> {...rest}
);
}
async function SpaceRefCard(
props: { resolved: ResolvedContentRef } & BlockProps<DocumentBlockContentRef>
) {
const { context, style, resolved } = props;
const spaceId = context.contentContext?.space.id;
if (!spaceId) {
return null;
}
return (
<Card
href={resolved.href}
title={resolved.text}
postTitle={resolved.subText}
style={style}
/> />
); );
} }
@@ -51,11 +51,13 @@ type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps &
* Ideally we'd rely on the block type to determine if it can be full width, but * Ideally we'd rely on the block type to determine if it can be full width, but
* the block's `fullWidth` property does not differentiate between `undefined` and `false`. * the block's `fullWidth` property does not differentiate between `undefined` and `false`.
* So instead we hardcode a list of blocks that can be full width. */ * So instead we hardcode a list of blocks that can be full width. */
const FULL_WIDTH_BLOCKS = [ const FULL_WIDTH_BLOCKS: DocumentBlock['type'][] = [
'table', 'table',
'tabs', 'tabs',
'integration', 'integration',
'openapi', 'openapi-operation',
'openapi-schemas',
'openapi-webhook',
'images', 'images',
'embed', 'embed',
'columns', 'columns',
@@ -64,6 +66,8 @@ const FULL_WIDTH_BLOCKS = [
'hint', 'hint',
]; ];
const LIST_BLOCKS: DocumentBlock['type'][] = ['list-ordered', 'list-tasks', 'list-unordered'];
/** /**
* Renders a list of blocks without a wrapper element. * Renders a list of blocks without a wrapper element.
*/ */
@@ -85,10 +89,11 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
key={node.key || `${node.type}-${index}`} key={node.key || `${node.type}-${index}`}
block={node} block={node}
style={[ style={[
'mx-auto page-width-wide:mx-0 w-full decoration-primary/6 print:break-inside-avoid', 'mx-auto page-width-wide:mx-0 w-full decoration-primary/6',
node.data && 'fullWidth' in node.data && node.data.fullWidth node.data && 'fullWidth' in node.data && node.data.fullWidth
? 'max-w-screen-xl' ? 'max-w-screen-xl'
: 'max-w-3xl', : 'max-w-3xl',
!LIST_BLOCKS.includes(node.type) && 'print:break-inside-avoid',
FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full', FULL_WIDTH_BLOCKS.includes(node.type) && 'page-width-wide:max-w-full',
blockStyle, blockStyle,
]} ]}
@@ -1,11 +1,14 @@
'use client'; 'use client';
import type { DocumentBlockCode } from '@gitbook/api'; import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive'; import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener'; import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener'; import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts'; import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block'; import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression'; import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
@@ -16,14 +19,17 @@ import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[]; inlines: RenderedInline[];
inlineExprVariables: InlineExpressionVariables; inlineExprVariables: InlineExpressionVariables;
mode: BlockProps<DocumentBlockCode>['context']['mode'];
}; };
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
/** /**
* Render a code-block client-side by loading the highlighter asynchronously. * Render a code-block client-side by loading the highlighter asynchronously.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting. * It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/ */
export function ClientCodeBlock(props: ClientBlockProps) { export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines, inlineExprVariables } = props; const { block, mode, style, inlines, inlineExprVariables } = props;
const blockRef = useRef<HTMLDivElement>(null); const blockRef = useRef<HTMLDivElement>(null);
const isInViewportRef = useRef(false); const isInViewportRef = useRef(false);
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
@@ -113,13 +119,84 @@ export function ClientCodeBlock(props: ClientBlockProps) {
setLines(null); setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]); }, [isInViewport, block, inlines, evaluateInlineExpression]);
return ( const expandable = block.data.expandable;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(
expandable && mode !== 'print' && numberOfLinesOfCode > collapsedLineCount
);
const codeBlockBodyId = useId();
const renderer = (
<CodeBlockRenderer <CodeBlockRenderer
ref={blockRef} ref={blockRef}
aria-busy={highlighting} aria-busy={highlighting}
block={block} block={block}
style={style} style={style}
lines={lines ?? plainLines} lines={lines ?? plainLines}
id={codeBlockBodyId}
/> />
); );
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
>
{renderer}
</CodeBlockExpandable>
) : (
renderer
);
}
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
collapsedLineCount: number;
controls?: string;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls(
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
)}
style={
{
'--line-count': collapsedLineCount,
'--line-height': '1.25rem',
} as React.CSSProperties
}
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<Button
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
size="xsmall"
variant="blank"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
aria-expanded={isExpanded}
aria-controls={controls}
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
</Button>
</div>
</div>
);
} }
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body }; return { inline, body };
}); });
if (!isEstimatedOffscreen && !hasInlineExpression) { if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
// In v2, we render the code block server-side // In v2, we render the code block server-side
const lines = await highlight(block, richInlines); const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />; return <CodeBlockRenderer block={block} style={style} lines={lines} />;
@@ -73,6 +73,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
style={style} style={style}
inlines={richInlines} inlines={richInlines}
inlineExprVariables={variables} inlineExprVariables={variables}
mode={context.mode}
/> />
</React.Suspense> </React.Suspense>
); );
@@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[]; lines: HighlightLine[];
'aria-busy'?: boolean; 'aria-busy'?: boolean;
id?: string;
}; };
/** /**
@@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) { ) {
const { block, style, lines, 'aria-busy': ariaBusy } = props; const { block, style, lines, 'aria-busy': ariaBusy } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap'; const withWrap = block.data.overflow === 'wrap';
const title = block.data.title; const title = block.data.title;
const id = useId();
const codeId = props.id || id;
return ( return (
<div <div
ref={ref} ref={ref}
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null} ) : null}
</div> </div>
<CopyCodeButton <CopyCodeButton
codeId={id} codeId={codeId}
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11" style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/> />
<pre <pre
@@ -53,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
)} )}
> >
<code <code
id={id} id={codeId}
className={tcls( className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap', 'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap' withWrap && 'whitespace-pre-wrap'
@@ -80,6 +80,7 @@ export function Column(props: {
<div <div
className={tcls( className={tcls(
'flex flex-col', 'flex flex-col',
'@container/column',
(verticalAlignment === VerticalAlignment.Top || !verticalAlignment) && (verticalAlignment === VerticalAlignment.Top || !verticalAlignment) &&
'justify-start', 'justify-start',
verticalAlignment === VerticalAlignment.Middle && 'justify-center', verticalAlignment === VerticalAlignment.Middle && 'justify-center',
@@ -31,6 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
alt="Drawing" alt="Drawing"
sizes={imageBlockSizes} sizes={imageBlockSizes}
zoom zoom
loading="lazy"
/> />
</Caption> </Caption>
); );
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
sources={{ light: { src: embed.icon } }} sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]} sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer} resize={context.contentContext.imageResizer}
loading="lazy"
/> />
) : null ) : null
} }
@@ -17,29 +17,34 @@ export function Details(props: {
}) { }) {
const { children, id, className } = props; const { children, id, className } = props;
const detailsRef = React.useRef<HTMLDetailsElement>(null); const ref = React.useRef<HTMLDetailsElement>(null);
const [openFromHash, setOpenFromHash] = React.useState(false); const [openFromHash, setOpenFromHash] = React.useState(false);
const hash = useHash(); const hash = useHash();
/** /**
* Open the details element if the url hash refers to the id of the details element * Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element. * or the id of some element contained within the details element.
*/ */
React.useEffect(() => { React.useEffect(() => {
if (!hash || !detailsRef.current) { if (!hash || !ref.current) {
return; return;
} }
if (hash === id) { if (hash === id) {
setOpenFromHash(true); setOpenFromHash(true);
return;
} }
const activeElement = document.getElementById(hash); const activeElement = document.getElementById(hash);
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement))); const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
setOpenFromHash(isOpen);
}, [hash, id]); }, [hash, id]);
return ( return (
<details <details
ref={detailsRef} ref={ref}
id={id} id={id}
open={props.open || openFromHash} open={props.open || openFromHash}
className={tcls( className={tcls(
@@ -25,7 +25,11 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id = context.getId ? context.getId(id) : id; id = context.getId ? context.getId(id) : id;
return ( return (
<Details id={id} open={context.mode === 'print'} className={style}> <Details
id={id}
open={context.mode === 'print'}
className={tcls('scroll-mt-(--content-scroll-margin)', style)}
>
<summary <summary
className={tcls( className={tcls(
'cursor-pointer', 'cursor-pointer',
@@ -47,7 +51,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
className={tcls( className={tcls(
'inline-block', 'inline-block',
'size-3', 'size-3',
'mr-2', 'mr-3',
'mb-1', 'mb-1',
'transition-transform', 'transition-transform',
'shrink-0', 'shrink-0',
@@ -92,7 +96,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
document={document} document={document}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
context={context} context={context}
style={['px-10', 'pb-5', 'space-y-4']} style="space-y-4 px-10 pb-5"
/> />
</Details> </Details>
); );
@@ -1,10 +1,12 @@
import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api'; import { type DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { t } from '@/intl/translate';
import { getSimplifiedContentType } from '@/lib/files'; import { getSimplifiedContentType } from '@/lib/files';
import { resolveContentRef } from '@/lib/references'; import { resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { Link } from '../primitives'; import { getSpaceLanguage } from '@/intl/server';
import { Button, Link } from '../primitives';
import { DownloadButton } from '../primitives/DownloadButton';
import type { BlockProps } from './Block'; import type { BlockProps } from './Block';
import { Caption } from './Caption'; import { Caption } from './Caption';
import { FileIcon } from './FileIcon'; import { FileIcon } from './FileIcon';
@@ -12,67 +14,70 @@ import { FileIcon } from './FileIcon';
export async function File(props: BlockProps<DocumentBlockFile>) { export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props; const { block, context } = props;
const contentRef = context.contentContext if (!context.contentContext) {
? await resolveContentRef(block.data.ref, context.contentContext) return null;
: null; }
const contentRef = await resolveContentRef(block.data.ref, context.contentContext);
const file = contentRef?.file; const file = contentRef?.file;
if (!file) { if (!file) {
return null; return null;
} }
const language = getSpaceLanguage(context.contentContext);
const contentType = getSimplifiedContentType(file.contentType); const contentType = getSimplifiedContentType(file.contentType);
const insights = {
type: 'link_click' as const,
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
};
return ( return (
<Caption {...props} withBorder> <Caption {...props} withBorder>
<Link <div className="flex flex-wrap items-center gap-5 px-5 py-3">
href={file.downloadURL} <div className="flex min-w-14 flex-col items-center gap-1 border-tint-subtle border-r pr-5">
download={file.name} <FileIcon contentType={contentType} className="size-5 text-primary" />
insights={{ <div className="text-hint text-xs">{getHumanFileSize(file.size)}</div>
type: 'link_click', </div>
link: { <div className="min-w-24 flex-1">
target: block.data.ref, <div className="text-base">
position: SiteInsightsLinkPosition.Content, <Link
}, href={file.downloadURL}
}} target="_blank"
className={tcls('group/file', 'flex', 'flex-row', 'items-center', 'px-5', 'py-3')} insights={insights}
> className="hover:underline"
<div >
className={tcls( {file.name}
'min-w-14', </Link>
'mr-5',
'pr-5',
'flex',
'flex-col',
'items-center',
'gap-1',
'border-r',
'border-tint-subtle'
)}
>
<div>
<FileIcon
contentType={contentType}
className={tcls('size-5', 'text-primary')}
/>
</div> </div>
<div <div className="text-sm opacity-9 dark:opacity-8">{contentType}</div>
className={tcls( </div>
'text-xs', <div className="flex shrink-0 flex-wrap gap-2">
'text-tint', <DownloadButton
'group-hover/file:text-tint-strong' icon="download"
)} size="xsmall"
variant="secondary"
downloadUrl={file.downloadURL}
filename={file.name}
insights={insights}
> >
{getHumanFileSize(file.size)} {t(language, 'download')}
</div> </DownloadButton>
<Button
icon="arrow-up-right-from-square"
size="xsmall"
variant="secondary"
href={file.downloadURL}
target="_blank"
insights={insights}
>
{t(language, 'open')}
</Button>
</div> </div>
<div> </div>
<div className={tcls('text-base')}>{file.name}</div>
<div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>
{contentType}
</div>
</div>
</Link>
</Caption> </Caption>
); );
} }
@@ -9,7 +9,7 @@ export function FileIcon(props: { contentType: SimplifiedFileType | null; classN
const { contentType, className } = props; const { contentType, className } = props;
switch (contentType) { switch (contentType) {
case 'pdf': case 'PDF':
return <Icon icon="file-pdf" className={className} />; return <Icon icon="file-pdf" className={className} />;
case 'image': case 'image':
return <Icon icon="file-image" className={className} />; return <Icon icon="file-image" className={className} />;
@@ -1,6 +1,7 @@
import { type ClassValue, tcls } from '@/lib/tailwind'; import { type ClassValue, tcls } from '@/lib/tailwind';
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api'; import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { Link } from '../primitives';
import { getBlockTextStyle } from './spacing'; import { getBlockTextStyle } from './spacing';
/** /**
@@ -28,6 +29,8 @@ export function HashLinkButton(props: {
'h-[1em]', 'h-[1em]',
'border-0', 'border-0',
'opacity-0', 'opacity-0',
'site-background',
'rounded',
'group-hover/hash:opacity-[0]', 'group-hover/hash:opacity-[0]',
'group-focus/hash:opacity-[0]', 'group-focus/hash:opacity-[0]',
'md:group-hover/hash:opacity-[1]', 'md:group-hover/hash:opacity-[1]',
@@ -35,10 +38,10 @@ export function HashLinkButton(props: {
className className
)} )}
> >
<a <Link
href={`#${id}`} href={`#${id}`}
aria-label={label} aria-label={label}
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)} className={tcls('inline-flex h-full items-start', textStyle.lineHeight)}
> >
<Icon <Icon
icon="hashtag" icon="hashtag"
@@ -52,7 +55,7 @@ export function HashLinkButton(props: {
iconClassName iconClassName
)} )}
/> />
</a> </Link>
</div> </div>
); );
} }
@@ -36,7 +36,10 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
<HashLinkButton <HashLinkButton
id={id} id={id}
block={block} block={block}
className={tcls('-ml-6', 'pr-2')} className={tcls(
'-ml-6 pr-2',
'[.flip-heading-hash_&]:order-last [.flip-heading-hash_&]:ml-1 [.flip-heading-hash_&]:pl-2'
)}
iconClassName={tcls('size-4')} iconClassName={tcls('size-4')}
label="Direct link to heading" label="Direct link to heading"
/> />
@@ -5,6 +5,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { getSpaceLanguage, tString } from '@/intl/server'; import { getSpaceLanguage, tString } from '@/intl/server';
import { languages } from '@/intl/translations'; import { languages } from '@/intl/translations';
import { isHeadingBlock } from '@/lib/document';
import { Block, type BlockProps } from './Block'; import { Block, type BlockProps } from './Block';
import { Blocks } from './Blocks'; import { Blocks } from './Blocks';
import { getBlockTextStyle } from './spacing'; import { getBlockTextStyle } from './spacing';
@@ -18,7 +19,7 @@ export function Hint({
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info; const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
const firstNode = block.nodes[0]!; const firstNode = block.nodes[0]!;
const firstLine = getBlockTextStyle(firstNode); const firstLine = getBlockTextStyle(firstNode);
const hasHeading = ['heading-1', 'heading-2', 'heading-3'].includes(firstNode.type); const hasHeading = isHeadingBlock(firstNode);
const language = contextProps.context.contentContext const language = contextProps.context.contentContext
? getSpaceLanguage(contextProps.context.contentContext) ? getSpaceLanguage(contextProps.context.contentContext)
@@ -65,9 +66,9 @@ export function Hint({
{hasHeading ? ( {hasHeading ? (
<Block <Block
style={tcls( style={tcls(
'w-full items-start py-4! pl-3 text-[1em] *:flex-none', 'w-full items-start py-4! pl-3 text-[1em]! *:flex-none',
// Heading hash styles // Heading hash styles
'flip-heading-hash pr-8', 'flip-heading-hash pr-8 [&_.hash]:bg-transparent',
hintStyle.header hintStyle.header
)} )}
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
@@ -19,34 +19,42 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
className={tcls( className={tcls(
style, style,
'flex', 'flex',
'flex-row', 'w-full',
'gap-3',
align === 'center' && 'justify-center', align === 'center' && 'justify-center',
align === 'right' && 'justify-end', align === 'right' && 'justify-end',
align === 'left' && 'justify-start', align === 'left' && 'justify-start'
hasMultipleImages && ['grid', 'grid-flow-col'],
withFrame && [
'rounded-2xl',
'border',
'border-[rgb(234,235,238)]',
'dark:border-[rgb(45,50,58)]',
'relative',
'overflow-hidden',
]
)} )}
> >
{block.nodes.map((node: any, _i: number) => ( <div
<ImageBlock className={tcls(
key={node.key} 'flex',
block={node} 'flex-row',
document={document} 'gap-3',
style={[]} hasMultipleImages && ['grid', 'grid-flow-col'],
siblings={block.nodes.length} withFrame && [
context={context} 'rounded-2xl',
isEstimatedOffscreen={isEstimatedOffscreen} 'border',
withFrame={withFrame} 'border-[rgb(234,235,238)]',
/> 'dark:border-[rgb(45,50,58)]',
))} 'relative',
'overflow-hidden',
'w-fit',
]
)}
>
{block.nodes.map((node: any, _i: number) => (
<ImageBlock
key={node.key}
block={node}
document={document}
style={[]}
siblings={block.nodes.length}
context={context}
isEstimatedOffscreen={isEstimatedOffscreen}
withFrame={withFrame}
/>
))}
</div>
</div> </div>
); );
} }
@@ -135,8 +143,7 @@ async function ImageBlock(props: {
} }
: null, : null,
}} }}
priority={isEstimatedOffscreen ? 'lazy' : 'high'} loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
preload
zoom zoom
inlineStyle={{ inlineStyle={{
maxWidth: '100%', maxWidth: '100%',
@@ -1,31 +1,29 @@
import { resolveContentRef } from '@/lib/references'; import { resolveContentRef, resolveContentRefFallback } from '@/lib/references';
import * as api from '@gitbook/api'; import * as api from '@gitbook/api';
import type { IconName } from '@gitbook/icons'; import type { IconName } from '@gitbook/icons';
import { Button } from '../primitives'; import { Button } from '../primitives';
import type { InlineProps } from './Inline'; import type { InlineProps } from './Inline';
import { NotFoundRefHoverCard } from './NotFoundRefHoverCard';
export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) { export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline, context } = props; const { inline, context } = props;
if (!context.contentContext) { const resolved = context.contentContext
throw new Error('InlineButton requires a contentContext'); ? await resolveContentRef(inline.data.ref, context.contentContext)
} : null;
const resolved = await resolveContentRef(inline.data.ref, context.contentContext); const href = resolved?.href ?? resolveContentRefFallback(inline.data.ref)?.href;
if (!resolved) { const inlineElement = (
return null;
}
return (
// Set the leading to have some vertical space between adjacent buttons // Set the leading to have some vertical space between adjacent buttons
<span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2"> <span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2">
<Button <Button
href={resolved.href} href={href}
label={inline.data.label} label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons. // TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind} variant={inline.data.kind}
className="leading-normal" className="leading-normal"
disabled={href === undefined}
icon={inline.data.icon as IconName | undefined} icon={inline.data.icon as IconName | undefined}
insights={{ insights={{
type: 'link_click', type: 'link_click',
@@ -37,4 +35,10 @@ export async function InlineButton(props: InlineProps<api.DocumentInlineButton>)
/> />
</span> </span>
); );
if (!resolved) {
return <NotFoundRefHoverCard context={context}>{inlineElement}</NotFoundRefHoverCard>;
}
return inlineElement;
} }
@@ -1,4 +1,4 @@
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api'; import type { DocumentInlineIcon } from '@gitbook/api';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
@@ -7,16 +7,11 @@ import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) { export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props; const { inline } = props;
const icon = inline.data.icon as IconName; const { color, icon } = inline.data;
// @ts-expect-error remove this comment once API is updated
const color = inline.data.color
? // @ts-expect-error remove "as DocumentMarkColor['data']['text']" once API is updated
(inline.data.color as DocumentMarkColor['data']['text'])
: undefined;
return ( return (
<Icon <Icon
icon={icon} icon={icon as IconName}
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)} className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
/> />
); );
@@ -49,8 +49,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
} }
: null, : null,
}} }}
priority="lazy" loading="lazy"
preload
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]} style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline inline
zoom={!isInLink} zoom={!isInLink}
@@ -1,12 +1,17 @@
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api'; import { type ContentRef, type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
import { getSpaceLanguage, tString } from '@/intl/server'; import { getSpaceLanguage, tString } from '@/intl/server';
import { type TranslationLanguage, languages } from '@/intl/translations'; import { type TranslationLanguage, languages } from '@/intl/translations';
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references'; import {
type ResolvedContentRef,
resolveContentRef,
resolveContentRefFallback,
} from '@/lib/references';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives'; import { StyledLink } from '../../primitives';
import type { InlineProps } from '../Inline'; import type { InlineProps } from '../Inline';
import { Inlines } from '../Inlines'; import { Inlines } from '../Inlines';
import { NotFoundRefHoverCard } from '../NotFoundRefHoverCard';
import { InlineLinkTooltip } from './InlineLinkTooltip'; import { InlineLinkTooltip } from './InlineLinkTooltip';
export async function InlineLink(props: InlineProps<DocumentInlineLink>) { export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
@@ -18,52 +23,75 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
resolveAnchorText: false, resolveAnchorText: false,
}) })
: null; : null;
const { contentContext } = context; const { contentContext } = context;
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en; const inlinesElement = (
<Inlines
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
);
if (!contentContext || !resolved) { if (!resolved) {
const fallback = resolveContentRefFallback(inline.data.ref);
return ( return (
<HoverCardRoot> <NotFoundRefHoverCard context={context}>
<HoverCardTrigger> {fallback ? (
<span className="cursor-not-allowed underline"> <InlineLinkAnchor href={fallback.href} contentRef={inline.data.ref} isExternal>
<Inlines {inlinesElement}
context={context} </InlineLinkAnchor>
document={document} ) : (
nodes={inline.nodes} <span className="cursor-not-allowed underline">{inlinesElement}</span>
ancestorInlines={[...ancestorInlines, inline]} )}
/> </NotFoundRefHoverCard>
</span>
</HoverCardTrigger>
<HoverCard className="flex flex-col gap-1 p-4">
<div className="flex items-center gap-2">
<Icon icon="ban" className="size-4 text-tint-subtle" />
<h5 className="font-semibold">{tString(language, 'notfound_title')}</h5>
</div>
<p className="text-sm text-tint">{tString(language, 'notfound_link')}</p>
</HoverCard>
</HoverCardRoot>
); );
} }
const isExternal = inline.data.ref.kind === 'url'; const anchorElement = (
const isMailto = resolved.href.startsWith('mailto:'); <InlineLinkAnchor
const content = (
<StyledLink
href={resolved.href} href={resolved.href}
contentRef={inline.data.ref}
isExternal={inline.data.ref.kind === 'url'}
>
{inlinesElement}
</InlineLinkAnchor>
);
if (context.withLinkPreviews) {
const language = contentContext ? getSpaceLanguage(contentContext) : languages.en;
return (
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
{anchorElement}
</InlineLinkTooltipWrapper>
);
}
return anchorElement;
}
function InlineLinkAnchor(props: {
href: string;
contentRef: ContentRef;
isExternal?: boolean;
children: React.ReactNode;
}) {
const { href, isExternal, contentRef, children } = props;
const isMailto = href.startsWith('mailto:');
return (
<StyledLink
href={href}
insights={{ insights={{
type: 'link_click', type: 'link_click',
link: { link: {
target: inline.data.ref, target: contentRef,
position: SiteInsightsLinkPosition.Content, position: SiteInsightsLinkPosition.Content,
}, },
}} }}
> >
<Inlines {children}
context={context}
document={document}
nodes={inline.nodes}
ancestorInlines={[...ancestorInlines, inline]}
/>
{isMailto ? ( {isMailto ? (
<Icon <Icon
icon="envelope" icon="envelope"
@@ -77,16 +105,6 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
) : null} ) : null}
</StyledLink> </StyledLink>
); );
if (context.withLinkPreviews) {
return (
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
{content}
</InlineLinkTooltipWrapper>
);
}
return content;
} }
/** /**
@@ -1,37 +1,9 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import { tcls } from '@/lib/tailwind';
import React from 'react'; import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
const LoadingValueContext = React.createContext<React.ReactNode>(null);
// To avoid polluting the RSC payload with the tooltip implementation,
// we lazily load it on the client side. This way, the tooltip is only loaded
// when the user interacts with the link, and it doesn't block the initial render.
const InlineLinkTooltipImpl = dynamic(
() => import('./InlineLinkTooltipImpl').then((mod) => mod.InlineLinkTooltipImpl),
{
// Disable server-side rendering for this component, it's only
// visible on user interaction.
ssr: false,
loading: () => {
// The fallback should be the children (the content of the link),
// but as next/dynamic is aiming for feature parity with React.lazy,
// it doesn't support passing children to the loading component.
// https://github.com/vercel/next.js/issues/7906
const children = React.useContext(LoadingValueContext);
return <>{children}</>;
},
}
);
/**
* Tooltip for inline links. It's lazily loaded to avoid blocking the initial render
* and polluting the RSC payload.
*
* The link text and href have already been rendered on the server for good SEO,
* so we can be as lazy as possible with the tooltip.
*/
export function InlineLinkTooltip(props: { export function InlineLinkTooltip(props: {
isSamePage: boolean; isSamePage: boolean;
isExternal: boolean; isExternal: boolean;
@@ -45,28 +17,79 @@ export function InlineLinkTooltip(props: {
openInNewTabLabel: string; openInNewTabLabel: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { children, ...rest } = props; const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
const [shouldLoad, setShouldLoad] = React.useState(false);
// Once the browser is idle, we set shouldLoad to true. return (
// NOTE: to be slightly more performant, we could load when a link is hovered. <HoverCardRoot>
// But I found this was too much of a delay for the tooltip to appear. <HoverCardTrigger>{children}</HoverCardTrigger>
// Loading on idle is a good compromise, as it allows the initial render to be fast, <HoverCard className="p-4">
// while still loading the tooltip in the background and not polluting the RSC payload. <div className="flex items-start gap-4">
React.useEffect(() => { <div className="flex flex-col">
if ('requestIdleCallback' in window) { {breadcrumbs && breadcrumbs.length > 0 ? (
(window as globalThis.Window).requestIdleCallback(() => setShouldLoad(true)); <div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
} else { {breadcrumbs.map((crumb, index) => {
// fallback for old browsers const Tag = crumb.href ? StyledLink : 'div';
setTimeout(() => setShouldLoad(true), 2000);
}
}, []);
return shouldLoad ? ( return (
<LoadingValueContext.Provider value={children}> <Fragment key={crumb.label}>
<InlineLinkTooltipImpl {...rest}>{children}</InlineLinkTooltipImpl> {index !== 0 ? (
</LoadingValueContext.Provider> <Icon
) : ( icon="chevron-right"
children className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
</HoverCardRoot>
); );
} }
@@ -1,95 +0,0 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon } from '@gitbook/icons';
import { Fragment } from 'react';
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
export function InlineLinkTooltipImpl(props: {
isSamePage: boolean;
isExternal: boolean;
breadcrumbs: Array<{ href?: string; label: string; icon?: React.ReactNode }>;
target: {
href: string;
text: string;
subText?: string;
icon?: React.ReactNode;
};
openInNewTabLabel: string;
children: React.ReactNode;
}) {
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
return (
<HoverCardRoot>
<HoverCardTrigger>{children}</HoverCardTrigger>
<HoverCard className="p-4">
<div className="flex items-start gap-4">
<div className="flex flex-col">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{breadcrumbs.map((crumb, index) => {
const Tag = crumb.href ? StyledLink : 'div';
return (
<Fragment key={crumb.label}>
{index !== 0 ? (
<Icon
icon="chevron-right"
className="size-3 text-tint-subtle"
/>
) : null}
<Tag
className={tcls(
'flex gap-1',
crumb.href &&
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
)}
href={crumb.href ?? '#'}
>
{crumb.icon ? (
<span className="mt-0.5 text-tint-subtle empty:hidden">
{crumb.icon}
</span>
) : null}
{crumb.label}
</Tag>
</Fragment>
);
})}
</div>
) : null}
<div
className={tcls(
'flex gap-2 leading-snug',
isExternal && 'wrap-anywhere text-sm'
)}
>
{target.icon ? (
<div className="mt-1 text-tint-subtle empty:hidden">
{target.icon}
</div>
) : null}
<h5 className="font-semibold">{target.text}</h5>
</div>
</div>
{!isSamePage && target.href ? (
<Button
className={tcls(
'-mx-2 -my-2 ml-auto',
breadcrumbs?.length === 0 ? 'place-self-center' : null
)}
variant="blank"
href={target.href}
target="_blank"
label={openInNewTabLabel}
size="small"
icon="arrow-up-right-from-square"
iconOnly={true}
/>
) : null}
</div>
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
</HoverCard>
</HoverCardRoot>
);
}
@@ -27,7 +27,10 @@ export const contentKitServerContext: ContentKitServerContext = {
codeBlock: (props) => { codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />; return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
}, },
// For some reason, Next thinks that this function is used in a client component
// it's likely an issue with the compiler not being able to track the usage of this function properly
markdown: async ({ className, markdown }) => { markdown: async ({ className, markdown }) => {
'use server';
const parsed = await parseMarkdown(markdown); const parsed = await parseMarkdown(markdown);
return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />; return <div className={className} dangerouslySetInnerHTML={{ __html: parsed }} />;
}, },
@@ -32,8 +32,6 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
ancestorBlocks={[...ancestorBlocks, block]} ancestorBlocks={[...ancestorBlocks, block]}
blockStyle={tcls( blockStyle={tcls(
'min-h-lh', 'min-h-lh',
// flip heading hash icon if list item is a heading
'flip-heading-hash',
// remove margin-top for the first heading in a list // remove margin-top for the first heading in a list
'[h2]:pt-0', '[h2]:pt-0',
'[h3]:pt-0', '[h3]:pt-0',

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