Compare commits

...

80 Commits

Author SHA1 Message Date
Zeno Kapitein d8cfb10974 Format 2025-05-01 10:49:32 +02:00
Zeno Kapitein 3cdba0ae8e Cleanup & format 2025-05-01 10:49:32 +02:00
Zeno Kapitein 83e02b621a Iteration 4 2025-05-01 10:49:32 +02:00
Zeno Kapitein cb2cc52c26 Third iteration 2025-05-01 10:49:32 +02:00
Zeno Kapitein 0e15229c52 Second iteration 2025-05-01 10:49:32 +02:00
Zeno Kapitein 8547867c6d Initial version 2025-05-01 10:49:32 +02:00
Taran Vohra 5a69692f54 Temporarily bump getPublishedContentByUrl timeout to 30s on v1 (#3199) 2025-04-30 19:14:09 +05:30
Nolann B. 42ca7e1580 Fix openapi CR preview (#3197) 2025-04-30 11:44:39 +02:00
Nolann B. c4ebb3fecd Fix openapi-select hover in responses (#3196) 2025-04-30 11:27:03 +02:00
Zeno Kapitein 2a805cc082 Change OpenAPI schema-optional from info to tint color (#3194) 2025-04-29 19:30:11 +02:00
Zeno Kapitein 89a5816ee4 Fix OpenAPI disclosure label ("Show properties") misalignment on mobile (#3193) 2025-04-29 19:29:56 +02:00
Zeno Kapitein c3f6b8c003 Reduce chroma of text layers (#3188) 2025-04-29 19:29:42 +02:00
Zeno Kapitein 0e201d5899 Add border to filled sidebar on gradient theme (#3185) 2025-04-29 19:29:32 +02:00
Zeno Kapitein ae5f1abc58 Change Dropdowns to use Radix's DropdownMenu (#3189) 2025-04-29 18:30:47 +02:00
Nolann B. 580101d04e Schemas disclosure label causing client error (#3192) 2025-04-29 16:16:58 +02:00
Johan Preynat 81b4a4db53 Allow /~gitbook/image and /~gitbook/icon despite querystring parameters for SEO in robots.txt (#3191) 2025-04-28 12:07:34 +02:00
Steven H 8339e91e2b Fix images and other content refs in reusable content across spaces. (#3190) 2025-04-25 15:28:09 +01:00
Steven H 3119066728 Support for reusable content across spaces. (#3173) 2025-04-24 16:54:11 +01:00
Zeno Kapitein 326e28e9b0 Restyle openapi blocks (#3169) 2025-04-24 16:53:29 +02:00
Johan Preynat 90f0127ada Use react@19 on v2 and v1 (#3184) 2025-04-24 16:53:23 +02:00
Viktor Renkema 0353a8186c Have all nodes within a text-cells adhere to alignments (#3175) 2025-04-24 11:43:20 +02:00
Steven H dd043df00f Revert investigation work around URL caches. (#3183) 2025-04-23 16:53:33 +01:00
Steven H 97b7c79bc6 Increase logging around caching behaviour causing page crashes. (#3182) 2025-04-23 15:04:00 +01:00
Steven H 634e0b4302 Improve error messages around undefined site sections. (#3179) 2025-04-23 11:47:28 +00:00
Greg Bergé 2a23f1f003 Improve performances of TOC (#3176) 2025-04-23 09:31:22 +02:00
Samy Pessé 88ffdcc303 Version Packages (#3172)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-22 11:20:21 +02:00
Nolann B. b6b09d42af Fix OpenAPI responses select placement and icon (#3174) 2025-04-22 10:59:42 +02:00
Nolann B. ebc39e9db9 Missing select icon (#3171) 2025-04-18 16:26:59 +02:00
Samy Pessé 0df3849ea2 Version Packages (#3157)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-18 13:40:34 +02:00
Greg Bergé 8ed1bda2f2 Translate OpenAPI blocks (#3166) 2025-04-18 13:29:55 +02:00
Nolann B. d67699a1f1 Add OpenAPI Webhook block (#3160) 2025-04-18 08:35:44 +02:00
Greg Bergé 3363a18856 Merge simple alternatives (#3165) 2025-04-16 09:14:12 +00:00
Amrit Kahlon ad1dc0b914 Bump scalar version (#3161)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-04-15 18:00:07 -07:00
Samy Pessé e4c7307c99 Fix CORS error when loading fonts in 2c (#3163) 2025-04-14 17:05:31 -07:00
Samy Pessé f9c942c8ff Fix CORS error when loading icons in 2c (#3162) 2025-04-14 16:52:39 -07:00
Greg Bergé eeb977fc03 Fix Python JSON code example (#3159) 2025-04-14 19:50:40 +00:00
Greg Bergé 4b8a62122a Show sections tabs only if there is at least two sections (#3158) 2025-04-14 14:09:34 +02:00
Addison e44e947fc1 Update cookie banner to use American English (#3154) 2025-04-14 18:43:01 +09:00
Nolann B. 7588cfe1c5 Improve OpenAPIResponses examples and schemas (#3123) 2025-04-14 11:16:35 +02:00
Greg Bergé a9a643f222 Improve icon stabilization (#3147) 2025-04-13 05:41:47 +00:00
Samy Pessé 77d6cb75be Improve error message when image resizing failed (#3148) 2025-04-12 17:44:23 -07:00
Samy Pessé 1942d221df Bump the cache of getPublishedContentByUrl in v1 (#3146) 2025-04-12 07:18:26 -07:00
Greg Bergé 041601ed65 Upgrade Argos (#3145) 2025-04-12 15:48:51 +02:00
Steven H 5567281a85 Bump getPublishedContentByUrl cache. (#3144) 2025-04-11 17:13:01 +01:00
Samy Pessé 4c3753046b Cloudflare: fix image resizing (#3143) 2025-04-11 16:48:43 +02:00
Samy Pessé 89f8e16184 Cloudflare: resize images in v2 using direct fetch (#3141) 2025-04-11 15:18:44 +02:00
Samy Pessé a3e1125ced Hotfix for v1 crashes (#3139) 2025-04-10 22:00:41 +02:00
Samy Pessé 4dae8acfed Cloudflare: fix image resizing (#3138) 2025-04-10 19:30:15 +02:00
Samy Pessé bc081c4344 Fix cache revalidation for computed revisions (#3134) 2025-04-10 16:36:02 +02:00
Samy Pessé e4275b235d Version Packages (#3137)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-10 13:31:27 +02:00
Samy Pessé 77397cac82 Fix version of @gitbook/api referenced in cache-tags (#3136) 2025-04-10 13:30:28 +02:00
Samy Pessé b6a08bc679 Version Packages (#3084)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-04-10 13:18:22 +02:00
Samy Pessé 116575c811 Changeset for cache-tags (#3135) 2025-04-10 13:16:01 +02:00
Samy Pessé 4ba7bd5ce1 CF Preview URL: Extract worker URL and run visual tests (#3132) 2025-04-09 18:25:37 +02:00
Samy Pessé 223ab5377f Test cloudflare preview URL (#3130) 2025-04-09 17:24:38 +02:00
Samy Pessé ae43ca146c Fix preview URLs on Cloudflare env (#3129) 2025-04-09 16:00:21 +02:00
Taran Vohra 42342893d6 Fix incoming URL for requests that were proxied (#3127) 2025-04-09 19:10:14 +05:30
Nolann B. eedefdd5c9 Handle optional security headers (#3128) 2025-04-09 15:07:33 +02:00
Samy Pessé f5e152dd74 Fix hovering links in docs.gitbook.com (#3125) 2025-04-09 11:43:12 +02:00
Zeno Kapitein 95ea22d9c5 Cache AI Page Link summary (#3106)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2025-04-09 09:59:35 +02:00
Taran Vohra f23023ddfa Use the proxy request identifier for storing the VA cookie in proxy sites (#3091) 2025-04-09 12:49:22 +05:30
Samy Pessé da74077954 Remove proxy package (#3124) 2025-04-08 23:58:19 +02:00
Nolann B. 29aaba5949 Override Scalar's overscroll-behavior (#3120) 2025-04-08 19:16:46 +02:00
Zeno Kapitein daf41fc728 Footer design tweaks (#3109) 2025-04-08 18:44:57 +02:00
Zeno Kapitein b08eb1af59 Fix list of variants overflow (#3114) 2025-04-08 18:44:44 +02:00
Zeno Kapitein 8322cd3e35 Fix sections being displayed when there is only 1 (#3113) 2025-04-08 18:44:08 +02:00
Samy Pessé 444039c142 Fix opening of links when v1 is embedded in an iframe (#3122) 2025-04-08 18:43:06 +02:00
Samy Pessé 3b0eff7f71 Fix getRevisionPageByPath in v2 not encoding the path correctly (#3121) 2025-04-08 16:29:08 +00:00
Samy Pessé c765463e38 Fix ogimage generation crashing when site is using a custom WOFF2 font (#3119) 2025-04-08 16:44:28 +02:00
Samy Pessé e8ca25b67e Fix caching in Cloudflare version using R2 as a backend (#3118) 2025-04-08 13:37:07 +02:00
Brett Jephson 528eee3fc6 RND-548: add subscript and superscript support (#3110) 2025-04-08 11:32:12 +01:00
Steven H 168a4fa998 Add support for buttons to GitBook. (#3117) 2025-04-08 09:18:29 +01:00
Samy Pessé 3ea75adba4 Update turbo to 2.5.0 and fix cache config (#3116) 2025-04-08 09:56:05 +02:00
Nolann B. cbd768a095 Improve OpenAPI codesample (#3090) 2025-04-08 09:35:22 +02:00
Samy Pessé 2d01653a78 Fix ogimage on Cloudflare and resizing of ads image (#3107) 2025-04-08 01:29:34 +02:00
Nolann B. aa3357a57d Fix OpenAPISchemas description padding (#3115) 2025-04-07 21:54:33 +02:00
Samy Pessé de53946896 Sanitize the back URL used when rendering PDF (#3111) 2025-04-07 17:03:09 +02:00
Samy Pessé d51204148e Revert debugging of ogimage errors (#3105) 2025-04-06 13:33:48 +01:00
Samy Pessé 319761a39e Fix revalidation not working because of memoization (#3104) 2025-04-06 01:27:22 +02:00
Samy Pessé 7fbf01a348 Update opennext to 1.0.0-beta.0 (#3102) 2025-04-05 23:51:12 +02:00
237 changed files with 8312 additions and 3665 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Fix images in reusable content across spaces.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix openapi-select hover in responses
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/colors": patch
---
Update chroma ratio per step
@@ -3,4 +3,4 @@
'gitbook': patch
---
Improve OpenAPI schema style
Fix openapi CR preview
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Implement retry logic for the DO cache to prevent when revalidating content.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Adds AI sidebar with recommendations based on browsing behaviour
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": minor
"gitbook": minor
---
Design tweaks to code blocks and OpenAPI pages
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/colors": patch
---
Desaturate text colors by decreasing chroma for the last steps of the color scale
-7
View File
@@ -1,7 +0,0 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
'gitbook': patch
---
Remove stable from x-stability
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve the error message returned by the revalidate endpoint.
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": minor
"gitbook": minor
---
Add support for reusable content across spaces.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Change OpenAPI schema-optional from `info` to `tint` color
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Fix schemas disclosure label causing client error
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Improve error messages around undefined site sections.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": minor
---
Do not set cookie to identify visitor for insights when disabled.
-6
View File
@@ -1,6 +0,0 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Improve OpenAPI schemas block ungrouped style. Classnames have changed, please refer to this PR to update GBX.
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook-v2": patch
"gitbook": patch
---
Increase logging around caching behaviour causing page crashes.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Change `Dropdown`s to use Radix's `DropdownMenu`
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add border to filled sidebar on gradient theme
-5
View File
@@ -1,5 +0,0 @@
---
"@gitbook/react-openapi": patch
---
Hide deprecated properties in examples
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Better error handling in cache revalidation.
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"gitbook-v2": patch
---
Revert investigation work around URL caches.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix OpenAPI disclosure label ("Show properties") misalignment on mobile
@@ -19,6 +19,12 @@ inputs:
deploy:
description: 'Deploy as main version for all traffic instead of uploading versions'
required: true
commitTag:
description: 'Commit branch to associate with the deployment'
required: true
commitMessage:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
description: "Deployment URL"
@@ -49,10 +55,13 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
run: bun run turbo build:v2:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
shell: bash
- id: deploy
name: Deploy to Cloudflare
@@ -61,9 +70,9 @@ runs:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '3.112.0'
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
- name: Outputs
shell: bash
env:
@@ -54,6 +54,7 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build Project Artifacts
@@ -62,6 +63,7 @@ runs:
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
+68 -13
View File
@@ -10,7 +10,7 @@ jobs:
deploy-v1-cloudflare:
name: Deploy v1 to Cloudflare Pages
runs-on: ubuntu-latest
environment:
environment:
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions:
@@ -40,6 +40,7 @@ jobs:
run: bun run turbo gitbook#build:cloudflare
env:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
GITBOOK_RUNTIME: cloudflare
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
@@ -56,7 +57,7 @@ jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
environment:
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
@@ -77,11 +78,11 @@ jobs:
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
environment:
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -95,9 +96,19 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
commitMessage: ${{ github.sha }}
- name: Extract Worker ID
id: extract-worker-id
if: ${{ !steps.deploy.outputs.deployment-url }}
run: |
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
@@ -123,14 +134,14 @@ jobs:
body: |
Summary of the deployments:
### Version 1 (production)
### Version 1
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
### Version 2 (experimental)
### Version 2
| Version | URL | Status |
| --- | --- | --- |
@@ -139,10 +150,10 @@ jobs:
### Test content
| Site | v1 | v2 |
| --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
| Site | `v1` | `2v` | `2c` |
| --- | --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v1:
runs-on: ubuntu-latest
@@ -184,11 +195,33 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-vercel'
visual-testing-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v1:
runs-on: ubuntu-latest
name: Visual Testing Customers v1
needs: deploy-v1-cloudflare
timeout-minutes: 6
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -208,7 +241,7 @@ jobs:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 6
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -225,6 +258,28 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 8
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v1:
runs-on: ubuntu-latest
name: PageSpeed Testing v1
+3 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy staging
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-vercel
with:
@@ -48,6 +48,8 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+3 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy staging
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-vercel
with:
@@ -48,6 +48,8 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+1
View File
@@ -9,6 +9,7 @@
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
+3 -3
View File
@@ -59,15 +59,15 @@ bun install
4. Start your local development server.
```
bun dev
bun dev:v2
```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
- http://localhost:3000/docs.gitbook.com
- http://localhost:3000/open-source.gitbook.io/midjourney
- http://localhost:3000/url/docs.gitbook.com
- http://localhost:3000/url/open-source.gitbook.io/midjourney
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
+2 -1
View File
@@ -20,7 +20,8 @@
"**/.wrangler/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json"
"packages/icons/src/data/*.json",
"packages/cache-do/worker-configuration.d.ts"
]
},
"formatter": {
+310 -237
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -4,15 +4,15 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.4.4",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.2.8",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.108.0"
"@gitbook/api": "0.113.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"private": true,
"scripts": {
+2 -2
View File
@@ -21,10 +21,10 @@
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^3.112.0"
"wrangler": "^4.10.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
"generate": "wrangler types",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
+1 -1
View File
@@ -14,7 +14,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"types": ["./.wrangler/types/runtime.d.ts"]
"types": ["./worker-configuration.d.ts"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["worker-configuration.d.ts"]
}
}
}
+12
View File
@@ -1,5 +1,17 @@
# @gitbook/cache-tags
## 0.3.1
### Patch Changes
- 77397ca: Fix version of @gitbook/api referenced in package.json
## 0.3.0
### Minor Changes
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
## 0.2.0
### Minor Changes
+2 -2
View File
@@ -8,9 +8,9 @@
"default": "./dist/index.js"
}
},
"version": "0.2.0",
"version": "0.3.1",
"dependencies": {
"@gitbook/api": "*",
"@gitbook/api": "^0.111.0",
"assert-never": "^1.2.1"
},
"devDependencies": {
+6
View File
@@ -1,5 +1,11 @@
# @gitbook/colors
## 0.3.2
### Patch Changes
- cdffd7c: Desaturate text colors by decreasing chroma for the last steps of the color scale
## 0.3.1
### Patch Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js"
}
},
"version": "0.3.1",
"version": "0.3.2",
"devDependencies": {
"typescript": "^5.5.3"
},
+15 -1
View File
@@ -220,7 +220,21 @@ export function colorScale(
continue;
}
const chromaRatio = index === 8 || index === 9 ? 1 : index * 0.05;
const chromaRatio = (() => {
switch (index) {
// Step 9 and 10 have max chroma, meaning they are fully saturated.
case 8:
case 9:
return 1;
// Step 11 and 12 have a reduced chroma
case 10:
return 0.4;
case 11:
return 0.1;
default:
return index * 0.05;
}
})();
const shade = {
L: targetL, // Blend lightness
+15
View File
@@ -1,5 +1,20 @@
# gitbook-v2
## 0.2.5
### Patch Changes
- Updated dependencies [77397ca]
- @gitbook/cache-tags@0.3.1
## 0.2.4
### Patch Changes
- 4234289: Fix incoming URL for requests that were proxied
- Updated dependencies [116575c]
- @gitbook/cache-tags@0.3.0
## 0.2.3
### Patch Changes
+20 -2
View File
@@ -7,8 +7,6 @@ const nextConfig = {
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
// This is needed to use 'use cache'
useCache: true,
// Content is fully static, we can cache it in the session memory cache for a long time
@@ -33,7 +31,9 @@ const nextConfig = {
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
@@ -53,6 +53,24 @@ const nextConfig = {
},
],
},
async headers() {
return [
{
source: '/~gitbook/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
],
},
];
},
};
export default nextConfig;
+14 -6
View File
@@ -1,10 +1,18 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import d1TagCache from '@opennextjs/cloudflare/d1-tag-cache';
import kvIncrementalCache from '@opennextjs/cloudflare/kv-cache';
import memoryQueue from '@opennextjs/cloudflare/memory-queue';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
queue: memoryQueue,
tagCache: d1TagCache,
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
tagCache: doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
},
}),
queue: doQueue,
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook-v2",
"version": "0.2.3",
"version": "0.2.5",
"private": true,
"dependencies": {
"next": "canary",
@@ -13,11 +13,11 @@
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0",
"p-memoize": "^7.1.1"
"assert-never": "^1.2.1"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.10",
"@opennextjs/cloudflare": "^1.0.0-beta.3",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
@@ -28,7 +28,7 @@
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"build:v2:cloudflare": "opennextjs-cloudflare build",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test",
"typecheck": "tsc --noEmit"
+40 -13
View File
@@ -19,6 +19,7 @@ import {
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import assertNever from 'assert-never';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env';
@@ -242,19 +243,45 @@ export async function fetchSiteContextByIds(
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
: null;
const siteSpace = (
siteStructure.type === 'siteSpaces' && siteStructure.structure
? siteStructure.structure
: sections?.current.siteSpaces
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) {
throw new Error('Site space not found');
}
// Parse the current siteSpace and siteSpaces based on the site structure type.
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
if (siteStructure.type === 'siteSpaces') {
const siteSpaces = siteStructure.structure;
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
const siteSpaces =
siteStructure.type === 'siteSpaces'
? siteStructure.structure
: (sections?.current.siteSpaces ?? []);
if (!siteSpace) {
throw new Error(
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
);
}
return { siteSpaces, siteSpace };
}
if (siteStructure.type === 'sections') {
assert(
sections,
`cannot find site space "${ids.siteSpace}" because parsed sections are missing siteStructure.type="sections" siteSection="${ids.siteSection}"`
);
const currentSection = sections.current;
const siteSpaces = currentSection.siteSpaces;
const siteSpace = currentSection.siteSpaces.find(
(siteSpace) => siteSpace.id === ids.siteSpace
);
if (!siteSpace) {
throw new Error(
`Site space "${ids.siteSpace}" not found in structure type="sections" currentSection="${currentSection.id}"`
);
}
return { siteSpaces, siteSpace };
}
// @ts-expect-error
assertNever(siteStructure, `cannot handle site structure of type ${siteStructure.type}`);
})();
const customization = (() => {
if (ids.siteSpace) {
@@ -380,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
const section = parseCurrentSection(structure, siteSectionId);
assert(section, 'A section must be defined when there are multiple sections');
assert(section, `couldn't find section "${siteSectionId}" in site structure`);
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import { getCloudflareContext as getCloudflareContextOpenNext } from '@opennextjs/cloudflare';
import { GITBOOK_RUNTIME } from '../env';
/**
* Return the Cloudflare context or null when not running in Cloudflare.
*/
export function getCloudflareContext() {
if (GITBOOK_RUNTIME !== 'cloudflare') {
return null;
}
return getCloudflareContextOpenNext();
}
/**
* Return an object representing the current request.
*/
export function getCloudflareRequestGlobal() {
const context = getCloudflareContext();
if (!context) {
return null;
}
return context.cf;
}
@@ -98,6 +98,10 @@ export async function wrapDataFetcherError<T>(
*/
export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
if (error.code >= 500) {
throw error;
}
return {
code: error.code,
message: error.errorMessage,
@@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
}
if (error instanceof DataFetcherError) {
if (error.code >= 500) {
throw error;
}
return {
code: error.code,
message: error.message,
@@ -4,3 +4,5 @@ export * from './pages';
export * from './urls';
export * from './errors';
export * from './lookup';
export * from './proxy';
export * from './visitor';
@@ -1,39 +1,52 @@
import { describe, expect, it, mock } from 'bun:test';
import { memoize } from './memoize';
import { AsyncLocalStorage } from 'node:async_hooks';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
describe('memoize', () => {
it('should memoize the function', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
expect(await memoized()).toBe(await memoized());
});
describe('withoutConcurrentExecution', () => {
it('should memoize the function based on the cache key', async () => {
const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b);
const memoized = withoutConcurrentExecution(() => null, fn);
it('should memoize the function with different arguments', async () => {
const fn = mock(async (a: number, b: number) => a + b);
const memoized = memoize(fn);
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
const p1 = memoized('c1', 1, 2);
const p2 = memoized('c1', 1, 2);
const p3 = memoized('c3', 2, 3);
expect(await p1).toBe(await p2);
expect(await p1).not.toBe(await p3);
expect(fn.mock.calls.length).toBe(2);
});
it('should memoize a function complex object', async () => {
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
const memoized = memoize(fn);
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
expect(fn.mock.calls.length).toBe(1);
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
await memoized({ foo: 'foo', bar: 2 })
);
expect(fn.mock.calls.length).toBe(2);
});
it('should wrap concurrent async calls', async () => {
it('should support caching per request', async () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
const promise1 = memoized();
const promise2 = memoized();
const request1 = { id: 'request1' };
const request2 = { id: 'request2' };
const requestContext = new AsyncLocalStorage<{ id: string }>();
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn);
// Both in the same request
const promise1 = requestContext.run(request1, () => memoized('c1'));
const promise2 = requestContext.run(request1, () => memoized('c1'));
// In a different request
const promise3 = requestContext.run(request2, () => memoized('c1'));
expect(await promise1).toBe(await promise2);
expect(await promise1).not.toBe(await promise3);
expect(fn.mock.calls.length).toBe(2);
});
});
describe('withCacheKey', () => {
it('should wrap the function by passing the cache key', async () => {
const fn = mock(
async (cacheKey: string, arg: { a: number; b: number }, c: number) =>
`${cacheKey}, result=${arg.a + arg.b + c}`
);
const memoized = withCacheKey(fn);
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
expect(fn.mock.calls.length).toBe(1);
});
});
+55 -11
View File
@@ -1,17 +1,61 @@
import pMemoize from 'p-memoize';
/**
* Wrap a function by preventing concurrent executions of the same function.
* With a logic to work per-request in Cloudflare Workers.
*/
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
getGlobalContext: () => object | null | undefined,
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
return (key: string, ...args: ArgsType) => {
const globalContext = getGlobalContext() ?? globalThis;
/**
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
* to avoid "Cannot perform I/O on behalf of a different request" errors.
*/
const promiseCache =
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
globalPromiseCache.set(globalContext, promiseCache);
const concurrent = promiseCache.get(key);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped(key, ...args);
return result;
} finally {
promiseCache.delete(key);
}
})();
promiseCache.set(key, promise);
return promise;
};
}
/**
* We wrap 'use cache' calls in a p-memoize function to avoid
* executing the function multiple times when doing concurrent calls.
*
* Hopefully one day this can be done directly by 'use cache'.
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function memoize<F extends (...args: any[]) => any>(f: F): F {
return pMemoize(f, {
cacheKey: (args) => {
return JSON.stringify(deepSortValue(args));
},
});
export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args));
}
function deepSortValue(value: unknown): unknown {
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
describe('isProxyRequest', () => {
it('should return true for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(isProxyRequest(proxyRequestURL)).toBe(true);
});
it('should return false for non-proxy requests', () => {
const nonProxyRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(isProxyRequest(nonProxyRequestURL)).toBe(false);
});
});
describe('getProxyRequestIdentifier', () => {
it('should return the correct identifier for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(getProxyRequestIdentifier(proxyRequestURL)).toBe('sites/site_foo');
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Check if the request to the site was through a proxy.
*/
export function isProxyRequest(requestURL: URL): boolean {
return (
requestURL.host === 'proxy.gitbook.site' || requestURL.host === 'proxy.gitbook-staging.site'
);
}
export function getProxyRequestIdentifier(requestURL: URL): string {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = requestURL.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
}
@@ -189,5 +189,6 @@ export interface GitBookDataFetcher {
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
tools?: api.AIToolCapabilities;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'bun:test';
import { getVisitorAuthBasePath } from './visitor';
describe('getVisitorAuthBasePath', () => {
it('should return the correct base path for proxy requests', () => {
expect(
getVisitorAuthBasePath(
new URL('https://proxy.gitbook.site/sites/site_foo/hello/world'),
{
site: 'site_foo',
siteSpace: 'sitesp_foo',
basePath: '/foo',
siteBasePath: '/foo',
organization: 'org_foo',
space: 'space_foo',
pathname: '/hello/world',
complete: false,
apiToken: 'api_token_foo',
canonicalUrl: 'https://example.com/docs/foo/hello/world',
}
)
).toBe('/sites/site_foo/');
});
it('should return the correct base path for non-proxy requests', () => {
expect(
getVisitorAuthBasePath(new URL('https://example.com/docs/foo/hello/world'), {
site: 'site_foo',
siteSpace: 'sitesp_foo',
basePath: '/foo/',
siteBasePath: '/foo/',
organization: 'org_foo',
space: 'space_foo',
pathname: '/hello/world',
complete: false,
apiToken: 'api_token_foo',
canonicalUrl: 'https://example.com/docs/foo/hello/world',
})
).toBe('/foo/');
});
});
@@ -0,0 +1,19 @@
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import type { PublishedSiteContent } from '@gitbook/api';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
/**
* Get the appropriate base path for the visitor authentication cookie.
*/
export function getVisitorAuthBasePath(
siteRequestURL: URL,
siteURLData: PublishedSiteContent
): string {
// The siteRequestURL for proxy requests is of the form `https://proxy.gitbook.com/site/siteId/...`
// In such cases, we should not use the resolved siteBasePath for the cookie because for subsequent requests
// we will not have the siteBasePath in the request URL in order to retrieve the cookie. So we use the
// proxy identifier instead.
return isProxyRequest(siteRequestURL)
? withLeadingSlash(withTrailingSlash(getProxyRequestIdentifier(siteRequestURL)))
: siteURLData.siteBasePath;
}
+26
View File
@@ -6,6 +6,14 @@ import 'server-only';
* and not from the `process.env` object.
*/
/**
* Runtime environment.
*/
export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as
| 'vercel'
| 'cloudflare'
| 'unknown';
/**
* Main host on which GitBook is running.
*/
@@ -77,6 +85,15 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
/**
* Mode used for resizing images.
*/
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
'GITBOOK_IMAGE_RESIZE_MODE',
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
['cdn-cgi', 'cf-fetch']
);
/**
* Endpoint where icons are served.
*/
@@ -92,3 +109,12 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
* Secret used to validate requests from the GitBook app.
*/
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
if (!enumValues.includes(value as T)) {
throw new Error(
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
);
}
return value as T;
}
@@ -0,0 +1,27 @@
import { checkIsHttpURL } from '@/lib/urls';
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}
if (input.includes('/~gitbook/image')) {
return false;
}
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}
return true;
}
@@ -1,34 +1,11 @@
import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookLinker } from '../links';
import { checkIsSizableImageURL } from './checkIsSizableImageURL';
import { getImageSize } from './resizer';
import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types';
interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
/**
* Create an image resizer for a rendering context.
*/
@@ -106,124 +83,6 @@ export function createNoopImageResizer(): ImageResizer {
};
}
/**
* Check if a URL is an HTTP URL.
*/
export function checkIsHttpURL(input: string | URL): boolean {
if (!URL.canParse(input)) {
return false;
}
const parsed = new URL(input);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}
if (input.includes('/~gitbook/image')) {
return false;
}
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}
return true;
}
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (_error) {
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
}
if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
}
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
}
return await fetch(
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`,
{
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
}
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
/**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing
@@ -1,13 +1,12 @@
import { getProxyRequestIdentifier, isProxyRequest } from '../data';
/**
* Get the site identifier to use for image resizing for an incoming request.
* This identifier can be obtained before resolving the request URL.
*/
export function getImageResizingContextId(url: URL): string {
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = url.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
if (isProxyRequest(url)) {
return getProxyRequestIdentifier(url);
}
return url.host;
@@ -3,3 +3,5 @@ export * from './createImageResizer';
export * from './signatures';
export * from './utils';
export * from './getImageResizingContextId';
export * from './resizer';
export * from './checkIsSizableImageURL';
@@ -0,0 +1,48 @@
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
/**
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
*/
export async function resizeImageWithCDNCgi(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
}
const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cdn-cgi: ${resizeURL}`);
return copyImageResponse(
await fetch(resizeURL, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
})
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
@@ -0,0 +1,34 @@
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
/**
* Resize an image by doing a request to the image itself using the Cloudflare fetch.
* https://developers.cloudflare.com/images/transform-images/transform-via-workers/
*
* This method doesn't work in Cloudflare Pages and is only supported in workers.
*/
export async function resizeImageWithCFFetch(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cf-fetch: ${input}`);
return copyImageResponse(
await fetch(input, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
cf: { image: resizeOptions },
})
);
}
@@ -0,0 +1,2 @@
export * from './types';
export * from './resizeImage';
@@ -0,0 +1,68 @@
import 'server-only';
import assertNever from 'assert-never';
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
import { checkIsSizableImageURL } from '../checkIsSizableImageURL';
import { resizeImageWithCDNCgi } from './cdn-cgi';
import { resizeImageWithCFFetch } from './cf-fetch';
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (error) {
console.warn(`Error getting image size for ${input}:`, error);
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
}
if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
}
switch (GITBOOK_IMAGE_RESIZE_MODE) {
case 'cdn-cgi':
return resizeImageWithCDNCgi(input, options);
case 'cf-fetch':
return resizeImageWithCFFetch(input, options);
default:
assertNever(GITBOOK_IMAGE_RESIZE_MODE);
}
}
@@ -0,0 +1,23 @@
export interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
@@ -0,0 +1,7 @@
/**
* Copy a response to make sure it can be mutated by the rest of the middleware.
* To avoid errors "Can't modify immutable headers".
*/
export function copyImageResponse(response: Response) {
return new Response(response.body, response);
}
@@ -32,6 +32,11 @@ export async function verifyImageSignature(
): Promise<boolean> {
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input);
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
console.log(
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
);
return generated === signature;
}
@@ -65,7 +70,9 @@ const generateSignatureV2: SignFn = async (input) => {
]
.filter(Boolean)
.join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
const signature = fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
return signature;
};
// Reused buffer for FNV-1a hashing in the v1 algorithm
+1 -12
View File
@@ -72,17 +72,6 @@ export function createLinker(
const siteBasePath = withTrailingSlash(withLeadingSlash(servedOn.siteBasePath));
const spaceBasePath = withTrailingSlash(withLeadingSlash(servedOn.spaceBasePath));
const protocol = (() => {
if (servedOn.protocol) {
return servedOn.protocol;
}
if (servedOn.host) {
return servedOn.host.startsWith('localhost') ? 'http:' : 'https:';
}
return 'https:';
})();
const linker: GitBookLinker = {
toPathInSpace(relativePath: string): string {
@@ -108,7 +97,7 @@ export function createLinker(
return absolutePath;
}
return `${protocol}//${joinPaths(servedOn.host, absolutePath)}`;
return `${servedOn.protocol ?? 'https:'}//${joinPaths(servedOn.host, absolutePath)}`;
},
toPathForPage({ pages, page, anchor }) {
+35 -27
View File
@@ -17,6 +17,7 @@ import { serveResizedImage } from '@/routes/image';
import {
DataFetcherError,
getPublishedContentByURL,
getVisitorAuthBasePath,
normalizeURL,
throwIfDataError,
} from '@v2/lib/data';
@@ -24,7 +25,6 @@ import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { getImageResizingContextId } from '@v2/lib/images';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import type { SiteURLData } from './lib/context';
export const config = {
matcher: [
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
@@ -137,24 +137,32 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return NextResponse.redirect(siteURLData.redirect);
}
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
cookies.push(
...getResponseCookiesForVisitorAuth(
getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorToken
)
);
// We use the host/origin from the canonical URL to ensure the links are
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
let incomingURL = requestURL;
// For cases where the site is proxied, we use the canonical URL
// as the incoming URL along with all the search params from the request.
if (mode !== 'url') {
incomingURL = siteCanonicalURL;
incomingURL.search = requestURL.search;
}
//
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
//
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (
requestURLWithoutToken !== incomingURL &&
requestURLWithoutToken.toString() !== incomingURL.toString()
) {
const incomingURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (incomingURLWithoutToken.toString() !== incomingURL.toString()) {
return writeResponseCookies(
NextResponse.redirect(requestURLWithoutToken.toString()),
NextResponse.redirect(incomingURLWithoutToken.toString()),
cookies
);
}
@@ -167,6 +175,23 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
@@ -174,7 +199,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
MiddlewareHeaders.SiteURL,
`${siteCanonicalURL.origin}${siteURLData.basePath}`
);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData));
// Preview of customization/theme
const customization = siteRequestURL.searchParams.get('customization');
@@ -204,23 +229,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
);
routeType = routeTypeFromPathname ?? routeType;
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const route = [
'sites',
routeType,
+137
View File
@@ -0,0 +1,137 @@
{
"main": ".open-next/worker.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_flags": ["nodejs_compat", "allow_importable_env"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"env": {
"preview": {
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-prod-api-cache"
}
]
// No durable objects on preview, as they block the generation of preview URLs
// and we don't need tags invalidation on preview
},
"staging": {
"routes": [
{
"pattern": "open-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
},
{
"pattern": "static-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-staging-api-cache"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
},
"production": {
"routes": [
{
"pattern": "open-2c.gitbook.com/*",
"zone_name": "gitbook.com"
},
{
"pattern": "static-2c.gitbook.com/*",
"zone_name": "gitbook.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-prod-api-cache"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
}
}
}
-50
View File
@@ -1,50 +0,0 @@
main = ".open-next/worker.js"
name = "gitbook-open-v2"
compatibility_date = "2025-03-11"
compatibility_flags = ["nodejs_compat", "allow_importable_env"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
observability = { enabled = true }
[env.preview]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "b7dd9cf58bf2458f84812a2d83b3760c" } # gitbook-open-v2-cache-preview
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "f59ddb40-ad72-4312-9395-0ac6a129af8e", database_name = "gitbook-open-v2-tags-preview" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-preview" },
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
]
[env.staging]
routes = [
{ pattern = "open-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" },
{ pattern = "static-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" }
]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "a446e25f12b741afb185f1e5b4474f0a" } # gitbook-open-v2-cache-staging
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "9df62e39-1f35-4066-83aa-e9b8ed3ac8d5", database_name = "gitbook-open-v2-tags-staging" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-staging" },
{ binding = "GITBOOK_API", service = "gitbook-x-staging-api-cache" }
]
[env.production]
routes = [
{ pattern = "open-2c.gitbook.com/*", zone_name = "gitbook.com" },
{ pattern = "static-2c.gitbook.com/*", zone_name = "gitbook.com" }
]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "72379746280d4e79acf24440eea950dc" } # gitbook-open-v2-cache-production
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "a6f16fce-5f45-43a9-89a4-7b83ddf25b77", database_name = "gitbook-open-v2-tags-production" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-production" },
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
]
+70
View File
@@ -1,5 +1,75 @@
# gitbook
## 0.11.1
### Patch Changes
- Updated dependencies [ebc39e9]
- Updated dependencies [b6b09d4]
- @gitbook/react-openapi@1.2.1
## 0.11.0
### Minor Changes
- d67699a: Add OpenAPI Webhook block
### Patch Changes
- 4b8a621: Show sections tabs only if there is at least two sections
- 8ed1bda: Translate OpenAPI blocks
- 7588cfe: Improve OpenAPIResponses examples and schemas
- Updated dependencies [eeb977f]
- Updated dependencies [3363a18]
- Updated dependencies [d67699a]
- Updated dependencies [8ed1bda]
- Updated dependencies [7588cfe]
- Updated dependencies [ad1dc0b]
- @gitbook/react-openapi@1.2.0
## 0.10.1
### Patch Changes
- Updated dependencies [77397ca]
- @gitbook/cache-tags@0.3.1
## 0.10.0
### Minor Changes
- b62b101: Do not set cookie to identify visitor for insights when disabled.
### Patch Changes
- 95ea22d: Cache AI Page Link summary
- daf41fc: Tweak footer design (and refactor)
- de53946: Fix security issue with injection of "javacript:` url in the back button of PDFs
- b92ecfa: Implement retry logic for the DO cache to prevent when revalidating content.
- 528eee3: Add superscript and subscript text rendering
- aa3357a: Fix OpenAPISchemas description padding
- 168a4fa: Add support for buttons to GitBook.
- 70c4182: Improve OpenAPI schema style
- 2b6c593: Remove stable from x-stability
- 580f7ad: Improve the error message returned by the revalidate endpoint.
- cbd768a: Improve OpenAPI codesample (add OpenAPISelect component)
- c765463: Fix ogimage generation crashing when site is using a custom WOFF2 font
- e59076a: Improve OpenAPI schemas block ungrouped style. Classnames have changed, please refer to this PR to update GBX.
- 29aaba5: Override Scalar's overscroll-behavior
- 90ead98: Better error handling in cache revalidation.
- Updated dependencies [116575c]
- Updated dependencies [cdffd7c]
- Updated dependencies [70c4182]
- Updated dependencies [2b6c593]
- Updated dependencies [cbd768a]
- Updated dependencies [e59076a]
- Updated dependencies [eedefdd]
- Updated dependencies [23cedd2]
- @gitbook/cache-tags@0.3.0
- @gitbook/colors@0.3.2
- @gitbook/react-openapi@1.1.10
- @gitbook/openapi-parser@2.1.3
## 0.9.2
### Patch Changes
+26 -22
View File
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
],
},
{
name: 'Nexthink',
contentBaseURL: 'https://docs.nexthink.com',
tests: [
{
name: 'Home',
url: '/',
screenshot: { waitForTOCScrolling: false },
run: waitForCookiesDialog,
},
],
},
// {
// name: 'Nexthink',
// contentBaseURL: 'https://docs.nexthink.com',
// tests: [
// {
// name: 'Home',
// url: '/',
// screenshot: { waitForTOCScrolling: false },
// run: waitForCookiesDialog,
// },
// ],
// },
{
name: 'asiksupport-stg.dto.kemkes.go.id',
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
@@ -92,11 +92,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'docs.tradeonnova.io',
contentBaseURL: 'https://docs.tradeonnova.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'azcoiner.gitbook.io',
contentBaseURL: 'https://azcoiner.gitbook.io',
@@ -162,11 +157,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://wiki.redmodding.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cherry-ai.com',
contentBaseURL: 'https://docs.cherry-ai.com',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
// {
// name: 'docs.cherry-ai.com',
// contentBaseURL: 'https://docs.cherry-ai.com',
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
// },
{
name: 'docs.snyk.io',
contentBaseURL: 'https://docs.snyk.io',
@@ -244,6 +239,15 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
},
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
];
runTestCases(testCases);
+79 -17
View File
@@ -111,24 +111,24 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
const spaceDrowpdown = page
const spaceDropdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="space-dropdown-button"] + div'
'css=[data-testid="dropdown-menu"]'
);
// the customized space title
await expect(
variantSelectionDropdown.getByRole('link', {
variantSelectionDropdown.getByRole('menuitem', {
name: 'Multi-Variants',
})
).toBeVisible();
// the NON-customized space title
await expect(
variantSelectionDropdown.getByRole('link', {
variantSelectionDropdown.getByRole('menuitem', {
name: 'RFCs',
})
).toBeVisible();
@@ -145,14 +145,17 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown
await page
.getByRole('link', {
await variantSelectionDropdown
.getByRole('menuitem', {
name: '2.0',
})
.click();
@@ -168,14 +171,18 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown
await page
.getByRole('link', {
await variantSelectionDropdown
.getByRole('menuitem', {
name: '2.0',
})
.click();
@@ -205,14 +212,18 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
},
run: async (page) => {
const spaceDrowpdown = await page
const spaceDropdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
await spaceDrowpdown.click();
await spaceDropdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown
await page
.getByRole('link', {
await variantSelectionDropdown
.getByRole('menuitem', {
name: '2.0',
})
.click();
@@ -419,6 +430,43 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Site subdirectory (proxy)',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://nextjs-gbo-proxy.vercel.app/documentation/',
tests: [
{
name: 'Main',
url: '',
fullPage: true,
},
],
},
{
name: 'Site subdirectory (proxy) with VA',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://nextjs-gbo-proxy-va.vercel.app/va/docs/',
tests: [
{
name: 'Main',
url: () => {
const privateKey =
'rqSfA6x7eAKx1qDRCDq9aCXwivpUvQ8YkXeDdFvCCUa9QchIcM7pF1iJ4o7AGOU49spmOWjKoIPtX0pVUVQ81w==';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
}
);
return `?jwt_token=${token}`;
},
fullPage: true,
},
],
},
{
name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -837,6 +885,20 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Content Redirects',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Redirect to new location',
url: '/content-editor/editing-content/inline/redirect-test',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('Redirect test');
},
screenshot: false,
},
],
},
{
name: 'Site Redirects with sections',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
+73 -46
View File
@@ -34,6 +34,10 @@ export interface Test {
* Test to run
*/
run?: (page: Page, response: Response | null) => Promise<unknown>;
/**
* Mode for the test.
*/
mode?: 'page' | 'image';
/**
* Whether the test should be fullscreened during testing.
*/
@@ -154,6 +158,7 @@ export function runTestCases(testCases: TestsCase[]) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const { mode = 'page' } = testEntry;
const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, context }) => {
const testEntryPathname =
@@ -194,24 +199,33 @@ export function runTestCases(testCases: TestsCase[]) {
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
const screenshotName = `${testCase.name} - ${testEntry.name}`;
if (mode === 'image') {
await argosScreenshot(page, screenshotName, {
viewports: ['macbook-13'],
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: true,
});
} else {
await argosScreenshot(page, screenshotName, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => {
await runStabilization();
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
await waitForIcons(page);
},
});
}
}
});
}
@@ -331,61 +345,74 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(() => {
const urls = new Set<string>();
const urlStates: Record<string, 'pending' | 'loaded'> =
(window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const loadUrl = (url: string) => {
// Mark the URL as pending.
urlStates[url] = 'pending';
const img = new Image();
img.onload = () => {
urlStates[url] = 'loaded';
};
img.src = url;
};
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
const results = icons.map((icon) => {
if (!(icon instanceof SVGElement)) {
throw new Error('Icon is not an SVGElement');
}
// If loaded, good it passes the test.
if (icon.dataset.loadingState === 'loaded') {
return true;
}
// If not loaded yet, we need to load it.
if (icon.dataset.loadingState === 'pending') {
return false;
}
// Ignore icons that are not visible.
if (!icon.checkVisibility()) {
return true;
}
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
return false;
}
if (state === 'loaded') {
return true;
}
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
const url = urlMatch?.[1];
// If URL is invalid we throw an error.
if (!url) {
throw new Error('No mask-image');
}
// If the URL is already loaded, we just mark it as loaded.
if (urls.has(url)) {
icon.dataset.loadingState = 'loaded';
return true;
// If the URL is already queued for loading, we return the state.
if (urlStates[url]) {
if (urlStates[url] === 'loaded') {
icon.setAttribute('data-argos-state', 'pending');
const bckMaskImage = icon.style.maskImage;
const bckDisplay = icon.style.display;
icon.style.maskImage = '';
icon.style.display = 'none';
requestAnimationFrame(() => {
icon.style.maskImage = bckMaskImage;
icon.style.display = bckDisplay;
requestAnimationFrame(() => {
icon.setAttribute('data-argos-state', 'loaded');
});
});
return false;
}
return false;
}
// Mark the icon as pending and load the image.
icon.dataset.loadingState = 'pending';
// Mark the URL as seen.
urls.add(url);
const img = new Image();
img.src = url;
img.decode().then(() => {
// Wait two frames to let the time to the icon to repaint.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
icon.dataset.loadingState = 'loaded';
});
});
});
loadUrl(url);
return false;
});
+1
View File
@@ -5,6 +5,7 @@ module.exports = {
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
},
webpack(config) {
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.9.2",
"version": "0.11.1",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -27,17 +27,18 @@
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@vercel/og": "0.6.8.",
"ai": "^4.2.2",
"assert-never": "^1.2.1",
"bun-types": "^1.1.20",
"classnames": "^2.5.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
@@ -53,8 +54,9 @@
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"partial-json": "^0.1.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
@@ -71,13 +73,11 @@
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"event-iterator": "^2.0.0",
"partial-json": "^0.1.7",
"zustand": "^5.0.3"
},
"devDependencies": {
"@argos-ci/playwright": "^4.3.0",
"@cloudflare/next-on-pages": "1.13.7",
"@argos-ci/playwright": "^5.0.3",
"@cloudflare/next-on-pages": "1.13.12",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.51.1",
"@types/js-cookie": "^3.0.6",
+4
View File
@@ -1,3 +1,7 @@
# GitBook immutable static assets
# Duplicated from next.config.mjs until OpenNext supports generating static headers
/~gitbook/static/*
cache-control: public,max-age=31536000,immutable
Access-Control-Allow-Origin: *
/_next/static/*
Access-Control-Allow-Origin: *
@@ -1,103 +1,14 @@
import { ImageResponse } from '@vercel/og';
import { notFound, redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';
import { getEmojiForCode } from '@/lib/emojis';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
import { getResizedImageURL } from '@v2/lib/images';
import { serveIcon } from '@/routes/icon';
export const runtime = 'edge';
const SIZES = {
/** Size for a favicon */
small: {
width: 48,
height: 48,
textSize: 'text-[32px]',
boxStyle: 'rounded-[8px]',
},
/** Size for display as an app icon or in the header */
medium: {
width: 256,
height: 256,
textSize: 'text-[164px]',
boxStyle: 'rounded-[32px]',
},
};
/**
* Generate an icon for a site content.
*/
export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const pointer = await getSiteContentPointer();
const context = await fetchV1ContextForSitePointer(pointer);
const size = SIZES[options.size];
const { site, customization } = context;
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
// If the site has a custom icon, redirect to it
if (customIcon) {
const iconUrl = options.theme === 'light' ? customIcon.light : customIcon.dark;
redirect(
await getResizedImageURL(context.imageResizer, iconUrl, {
width: size.width,
height: size.height,
})
);
}
const contentTitle = site.title;
return new ImageResponse(
<div
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle)}
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<h2
tw={tcls(
size.textSize,
'font-bold',
'tracking-tight',
options.theme === 'light' ? 'text-black' : 'text-white'
)}
>
{'emoji' in customization.favicon
? getEmojiForCode(customization.favicon.emoji)
: contentTitle.slice(0, 1).toUpperCase()}
</h2>
</div>,
{
width: size.width,
height: size.height,
}
);
}
function getOptions(inputUrl: string): {
size: keyof typeof SIZES;
theme: 'light' | 'dark';
} {
const url = new URL(inputUrl);
const sizeParam = (url.searchParams.get('size') ?? 'small') as keyof typeof SIZES;
const themeParam = url.searchParams.get('theme') ?? 'light';
if (!SIZES[sizeParam] || !['light', 'dark'].includes(themeParam)) {
notFound();
}
return {
// @ts-ignore
size: sizeParam,
// @ts-ignore
theme: themeParam,
};
return serveIcon(context, req);
}
@@ -0,0 +1,138 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Emoji } from '../primitives';
import { type SuggestedPage, useAdaptiveContext } from './AdaptiveContext';
import { streamNextPageSuggestions } from './server-actions/streamNextPageSuggestions';
export function AINextPageSuggestions() {
const { selectedJourney, open } = useAdaptiveContext();
const currentPage = usePageContext();
const visitedPages = useVisitedPages((state) => state.pages);
const [pages, setPages] = useState<SuggestedPage[]>(selectedJourney?.pages ?? []);
const [suggestedPages, setSuggestedPages] = useState<SuggestedPage[]>([]);
useEffect(() => {
let canceled = false;
if (selectedJourney?.pages && selectedJourney.pages.length > 0) {
setPages(selectedJourney.pages);
} else {
setPages(suggestedPages);
}
if (suggestedPages.length === 0) {
(async () => {
const stream = await streamNextPageSuggestions({
currentPage: {
id: currentPage.pageId,
title: currentPage.title,
},
currentSpace: {
id: currentPage.spaceId,
},
visitedPages: visitedPages,
});
for await (const page of stream) {
if (canceled) return;
setPages((prev) => [...prev, page]);
setSuggestedPages((prev) => [...prev, page]);
}
})();
}
return () => {
canceled = true;
};
}, [
selectedJourney,
currentPage.pageId,
currentPage.spaceId,
currentPage.title,
visitedPages,
suggestedPages,
]);
return (
open && (
<div className="animate-fadeIn">
<motion.div className="mb-2 flex flex-row items-start gap-3">
<AnimatePresence mode="wait">
{selectedJourney?.icon ? (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ delay: 0.2 }}
key={selectedJourney.icon}
>
<Icon
icon={selectedJourney.icon as IconName}
className="left-0 mt-2 size-6 shrink-0 text-tint-subtle"
/>
</motion.div>
) : null}
</AnimatePresence>
<motion.div className={tcls('flex flex-col')} layout="position">
<div className="font-semibold text-tint text-xs uppercase tracking-wide">
Suggested pages
</div>
<AnimatePresence mode="wait">
{selectedJourney?.label ? (
<motion.h5
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
key={selectedJourney.label}
className="font-semibold text-base"
>
{selectedJourney.label}
</motion.h5>
) : null}
</AnimatePresence>
</motion.div>
</motion.div>
<div className="-mb-1.5 flex flex-col gap-1">
{Object.assign(Array.from({ length: 5 }), pages).map(
(page: SuggestedPage | undefined, index) =>
page ? (
<Link
key={`${selectedJourney?.label}-${page.id}`}
className="-mx-2 flex animate-fadeIn gap-2 rounded px-2.5 py-1 transition-all hover:bg-tint-hover hover:text-tint-strong"
href={page.href}
style={{ animationDelay: `${0.2 + index * 0.05}s` }}
>
{page.icon ? (
<Icon
icon={page.icon as IconName}
className="mt-0.5 size-4 shrink-0 text-tint-subtle"
/>
) : null}
{page.emoji ? <Emoji code={page.emoji} /> : null}
{page.title}
</Link>
) : (
<div
key={index}
className="my-1 h-5 animate-pulse rounded bg-tint-hover"
style={{
animationDelay: `${index * 0.2}s`,
width: `${((index * 17) % 50) + 50}%`,
}}
/>
)
)}
</div>
</div>
)
);
}
@@ -0,0 +1,60 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons';
import { JOURNEY_COUNT, type Journey, useAdaptiveContext } from './AdaptiveContext';
export function AIPageJourneySuggestions() {
const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext();
return (
open && (
<div className="animate-fadeIn">
<div className="mb-2 flex flex-row items-center gap-1 font-semibold text-tint text-xs uppercase tracking-wide">
More to explore
</div>
<div className="grid grid-cols-2 gap-2">
{Object.assign(Array.from({ length: JOURNEY_COUNT }), journeys).map(
(journey: Journey | undefined, index) => {
const isSelected =
journey?.label && journey.label === selectedJourney?.label;
const isLoading = !journey || journey?.label === undefined;
return (
<button
type="button"
key={index}
disabled={journey?.label === undefined}
className={tcls(
'flex flex-col items-center justify-center gap-2 rounded bg-tint px-2 py-4 text-center ring-1 ring-tint-subtle ring-inset transition-all',
isLoading
? 'h-24 scale-90 animate-pulse'
: 'hover:bg-tint-hover hover:text-tint-strong hover:ring-tint active:scale-95',
isSelected &&
'bg-primary-active text-primary-strong ring-2 ring-primary hover:bg-primary-active hover:ring-primary'
)}
style={{
animationDelay: `${index * 0.2}s`,
}}
onClick={() =>
setSelectedJourney(isSelected ? undefined : journey)
}
>
{journey?.icon ? (
<Icon
icon={journey.icon as IconName}
className="size-4 animate-fadeIn text-tint-subtle [animation-delay:300ms]"
/>
) : null}
{journey?.label ? (
<span className="animate-fadeIn leading-tight [animation-delay:400ms]">
{journey.label}
</span>
) : null}
</button>
);
}
)}
</div>
</div>
)
);
}
@@ -2,12 +2,106 @@
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Loading } from '../primitives';
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
/**
* Get a unique cache key for a page summary
*/
function getCacheKey(targetSpaceId: string, targetPageId: string): string {
return `${targetSpaceId}:${targetPageId}`;
}
/**
* Global state for the summaries.
*/
const useSummaries = create<{
/**
* Cache of all summaries generated so far.
*/
cache: Map<string, string>;
/**
* Get a summary for a page.
*/
getSummary: (params: { targetSpaceId: string; targetPageId: string }) => string;
/**
* Stream the generation of a summary for a page.
*/
streamSummary: (params: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
visitedPages: { spaceId: string; pageId: string }[];
}) => Promise<void>;
}>((set, get) => ({
cache: new Map(),
getSummary: ({
targetSpaceId,
targetPageId,
}: {
targetSpaceId: string;
targetPageId: string;
}) => {
return get().cache.get(getCacheKey(targetSpaceId, targetPageId)) ?? '';
},
streamSummary: async ({
currentSpaceId,
currentPageId,
currentPageTitle,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
}) => {
const cacheKey = getCacheKey(targetSpaceId, targetPageId);
if (get().cache.has(cacheKey)) {
// Already generated or generating
return;
}
const update = (summary: string) => {
set((prev) => {
const newCache = new Map(prev.cache);
newCache.set(cacheKey, summary);
return { cache: newCache };
});
};
update('');
const stream = await streamLinkPageSummary({
currentSpaceId,
currentPageId,
currentPageTitle,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
let generatedSummary = '';
for await (const highlight of stream) {
generatedSummary = highlight ?? '';
update(generatedSummary);
}
},
}));
/**
* Summarise a page's content for use in a link preview
*/
@@ -21,37 +115,28 @@ export function AIPageLinkSummary(props: {
const { targetSpaceId, targetPageId, linkPreview, linkTitle, showTrademark = true } = props;
const currentPage = usePageContext();
const language = useLanguage();
const visitedPages = useVisitedPages((state) => state.pages);
const [highlight, setHighlight] = useState('');
const { summary, streamSummary } = useSummaries(
useShallow((state) => {
return {
summary: state.getSummary({ targetSpaceId, targetPageId }),
streamSummary: state.streamSummary,
};
})
);
useEffect(() => {
let canceled = false;
setHighlight('');
(async () => {
const stream = await streamLinkPageSummary({
currentSpaceId: currentPage.spaceId,
currentPageId: currentPage.pageId,
currentPageTitle: currentPage.title,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
for await (const highlight of stream) {
if (canceled) return;
setHighlight(highlight ?? '');
}
})();
return () => {
canceled = true;
};
streamSummary({
currentSpaceId: currentPage.spaceId,
currentPageId: currentPage.pageId,
currentPageTitle: currentPage.title,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
}, [
currentPage.pageId,
currentPage.spaceId,
@@ -61,6 +146,7 @@ export function AIPageLinkSummary(props: {
linkPreview,
linkTitle,
visitedPages,
streamSummary,
]);
const shimmerBlocks = [
@@ -76,14 +162,14 @@ export function AIPageLinkSummary(props: {
<div className="flex flex-col gap-1">
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{showTrademark ? (
<Loading className="size-4" busy={!highlight || highlight.length === 0} />
<Loading className="size-4" busy={!summary || summary.length === 0} />
) : (
<Icon icon="sparkle" className="size-3" />
)}
<h6 className="text-tint">{t(language, 'link_tooltip_ai_summary')}</h6>
</div>
{highlight.length > 0 ? (
<p className="animate-fadeIn">{highlight}</p>
{summary.length > 0 ? (
<p className="animate-fadeIn">{summary}</p>
) : (
<div className="mt-2 flex flex-wrap gap-2">
{shimmerBlocks.map((block, index) => (
@@ -94,7 +180,7 @@ export function AIPageLinkSummary(props: {
))}
</div>
)}
{highlight.length > 0 ? (
{summary.length > 0 ? (
<div className="animate-fadeIn text-tint-subtle text-xs">
{t(language, 'link_tooltip_ai_summary_description')}
</div>
@@ -0,0 +1,101 @@
'use client';
import React, { useEffect } from 'react';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { streamPageJourneySuggestions } from './server-actions';
export type SuggestedPage = {
id: string;
title: string;
href: string;
icon?: string;
emoji?: string;
};
export type Journey = {
label: string;
icon?: string;
pages?: Array<SuggestedPage>;
};
type AdaptiveContextType = {
journeys: Journey[];
selectedJourney: Journey | undefined;
setSelectedJourney: (journey: Journey | undefined) => void;
loading: boolean;
open: boolean;
setOpen: (open: boolean) => void;
};
export const AdaptiveContext = React.createContext<AdaptiveContextType | null>(null);
export const JOURNEY_COUNT = 4;
/**
* Client side context provider to pass information about the current page.
*/
export function JourneyContextProvider({
children,
spaces,
}: { children: React.ReactNode; spaces: { id: string; title: string }[] }) {
const [journeys, setJourneys] = React.useState<Journey[]>([]);
const [selectedJourney, setSelectedJourney] = React.useState<Journey | undefined>(undefined);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(true);
const currentPage = usePageContext();
const visitedPages = useVisitedPages((state) => state.pages);
useEffect(() => {
let canceled = false;
setJourneys([]);
(async () => {
const stream = await streamPageJourneySuggestions({
count: JOURNEY_COUNT,
currentPage: {
id: currentPage.pageId,
title: currentPage.title,
},
currentSpace: {
id: currentPage.spaceId,
},
allSpaces: spaces,
visitedPages,
});
for await (const journey of stream) {
if (canceled) return;
setJourneys((prev) => [...prev, journey]);
}
setLoading(false);
})();
return () => {
canceled = true;
};
}, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]);
return (
<AdaptiveContext.Provider
value={{ journeys, selectedJourney, setSelectedJourney, loading, open, setOpen }}
>
{children}
</AdaptiveContext.Provider>
);
}
/**
* Hook to use the adaptive context.
*/
export function useAdaptiveContext() {
const context = React.useContext(AdaptiveContext);
if (!context) {
throw new Error('useAdaptiveContext must be used within a AdaptiveContextProvider');
}
return context;
}
@@ -0,0 +1,23 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { AINextPageSuggestions } from './AINextPageSuggestions';
import { AIPageJourneySuggestions } from './AIPageJourneySuggestions';
import { useAdaptiveContext } from './AdaptiveContext';
import { AdaptivePaneHeader } from './AdaptivePaneHeader';
export function AdaptivePane() {
const { open } = useAdaptiveContext();
return (
<div
className={tcls(
'flex flex-col gap-4 rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300',
open ? 'w-72 px-4 py-4' : 'w-56 px-4 py-3'
)}
>
<AdaptivePaneHeader />
<AIPageJourneySuggestions />
<AINextPageSuggestions />
</div>
);
}
@@ -0,0 +1,40 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { AnimatePresence, motion } from 'framer-motion';
import { Button, Loading } from '../primitives';
import { useAdaptiveContext } from './AdaptiveContext';
export function AdaptivePaneHeader() {
const { loading, open, setOpen } = useAdaptiveContext();
return (
<div className="flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500">
<div className="flex grow flex-col">
<h4 className="flex items-center gap-1.5 font-semibold ">
<Loading className="size-4 text-tint-subtle" busy={loading} />
For you
</h4>
<AnimatePresence initial={false} mode="wait">
<motion.h5
key={loading ? 'loading' : 'loaded'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-tint-subtle text-xs"
>
{loading ? 'Basing on your context...' : 'Based on your context'}
</motion.h5>
</AnimatePresence>
</div>
<Button
variant="blank"
className={tcls('px-2 *:transition-transform', !open && '*:-rotate-45')}
iconOnly
label="Close"
icon="close"
onClick={() => setOpen(!open)}
/>
</div>
);
}
@@ -1 +1,3 @@
export * from './AIPageLinkSummary';
export * from './AdaptiveContext';
export * from './AdaptivePane';
@@ -1,5 +1,10 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import {
type AIMessageInput,
AIModel,
type AIStreamResponse,
type AIToolCapabilities,
} from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
@@ -47,11 +52,13 @@ export async function streamGenerateObject<T>(
schema,
messages,
model = AIModel.Fast,
tools = {},
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
tools?: AIToolCapabilities;
}
) {
const rawStream = context.dataFetcher.streamAIResponse({
@@ -62,12 +69,13 @@ export async function streamGenerateObject<T>(
type: 'object',
schema: zodToJsonSchema(schema),
},
tools,
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
if (event.type === 'response_object' && event.jsonChunk) {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
@@ -1 +1,2 @@
export * from './streamLinkPageSummary';
export * from './streamPageJourneySuggestions';
@@ -0,0 +1,128 @@
'use server';
import { resolvePageId } from '@/lib/pages';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a list of pages to read next
*/
export async function* streamNextPageSuggestions({
currentPage,
currentSpace,
visitedPages,
}: {
currentPage: {
id: string;
title: string;
};
currentSpace: {
id: string;
// title: string;
};
visitedPages?: Array<{ spaceId: string; pageId: string }>;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }, context] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
pages: z
.array(z.string().describe('The IDs of the page to read next.'))
.min(5)
.max(5),
}),
tools: {
getPages: true,
getPageContent: true,
},
messages: [
{
role: AIMessageRole.Developer,
content:
"You are a knowledge navigator. Given the user's visited pages and the documentation's table of contents, suggest a list of pages to read next.",
},
{
role: AIMessageRole.Developer,
content: `The user is in space (ID ${currentSpace.id})`,
},
// {
// role: AIMessageRole.Developer,
// content: `Other spaces in the documentation are: ${allSpaces
// .map(
// (space) => `
// - "${space.title}" (ID ${space.id})`
// )
// .join('\n')}
// Feel free to create journeys across spaces.`,
// },
{
role: AIMessageRole.Developer,
content: `The current page is: "${currentPage.title}" (ID ${currentPage.id}). You can use the getPageContent tool to get the content of any relevant links to include in the journey. Only follow links to pages.`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpace.id,
pageId: currentPage.id,
},
],
},
...(visitedPages && visitedPages.length > 0
? [
{
role: AIMessageRole.Developer,
content: `The user's visited pages are: ${visitedPages.map((page) => page.pageId).join(', ')}. The content of the last 5 pages are included below.`,
attachments: visitedPages.slice(0, 5).map((page) => ({
type: 'page' as const,
spaceId: page.spaceId,
pageId: page.pageId,
})),
},
]
: []),
],
}
),
fetchServerActionSiteContext(baseContext),
]);
const emitted = new Set<string>();
for await (const value of stream) {
const pages = value.pages;
if (!pages) continue;
for (const pageId of pages) {
if (!pageId) continue;
if (emitted.has(pageId)) continue;
emitted.add(pageId);
const resolvedPage = resolvePageId(context.pages, pageId);
if (!resolvedPage) continue;
yield {
id: resolvedPage.page.id,
title: resolvedPage.page.title,
icon: resolvedPage.page.icon,
emoji: resolvedPage.page.emoji,
href: context.linker.toPathForPage({
pages: context.pages,
page: resolvedPage.page,
}),
};
}
}
}
@@ -0,0 +1,190 @@
'use server';
import { type AncestorRevisionPage, resolvePageId } from '@/lib/pages';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole, type RevisionPageDocument } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamPageJourneySuggestions({
currentPage,
currentSpace,
allSpaces,
visitedPages,
count,
}: {
currentPage: {
id: string;
title: string;
};
currentSpace: {
id: string;
// title: string;
};
allSpaces: {
id: string;
title: string;
}[];
visitedPages?: Array<{ spaceId: string; pageId: string }>;
count: number;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream }, context] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
journeys: z
.array(
z.object({
label: z.string().describe('The label of the journey.'),
icon: z
.string()
.describe(
'The icon of the journey. Use an icon from FontAwesome, stripping the `fa-`. Examples: rocket-launch, tennis-ball, cat'
),
pages: z
.array(
z.object({
id: z.string(),
})
)
.describe(
'A list of pages in the journey, excluding the current page. Try to avoid duplicate content that is very similar.'
)
.min(5)
.max(10),
})
)
.describe('The possible journeys to take through the documentation.')
.min(count)
.max(count),
}),
tools: {
getPages: true,
getPageContent: true,
},
messages: [
{
role: AIMessageRole.Developer,
content:
"You are a knowledge navigator. Given the user's visited pages and the documentation's table of contents, suggest a named journey through the documentation. A journey is a list of pages that are related to each other. A journey's label starts with a verb and has a clear subject. Use sentence case (so only capitalize the first letter of the first word). Be concise and use short words to fit in the label. For example, use 'docs' instead of 'documentation'. Try to pick out specific journeys, not too generic.",
},
{
role: AIMessageRole.Developer,
content: `The user is in space "${allSpaces.find((space) => space.id === currentSpace.id)?.title}" (ID ${currentSpace.id})`,
},
{
role: AIMessageRole.Developer,
content: `Other spaces in the documentation are: ${allSpaces
.map(
(space) => `
- "${space.title}" (ID ${space.id})`
)
.join('\n')}
Feel free to create journeys across spaces.`,
},
{
role: AIMessageRole.Developer,
content: `The current page is: "${currentPage.title}" (ID ${currentPage.id}). You can use the getPageContent tool to get the content of any relevant links to include in the journey. Only follow links to pages.`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpace.id,
pageId: currentPage.id,
},
],
},
...(visitedPages && visitedPages.length > 0
? [
{
role: AIMessageRole.Developer,
content: `The user's visited pages are: ${visitedPages.map((page) => page.pageId).join(', ')}. The content of the last 5 pages are included below.`,
attachments: visitedPages.slice(0, 5).map((page) => ({
type: 'page' as const,
spaceId: page.spaceId,
pageId: page.pageId,
})),
},
]
: []),
],
}
),
fetchServerActionSiteContext(baseContext),
]);
const emitted: { label: string; pageIds: string[] }[] = [];
const allEmittedPageIds = new Set<string>();
for await (const value of stream) {
const journeys = value.journeys;
if (!journeys) continue;
for (const journey of journeys) {
if (!journey?.label) continue;
if (!journey?.pages || journey.pages?.length === 0) continue;
if (emitted.find((item) => item.label === journey.label)) continue;
const pageIds: string[] = [];
const resolvedPages: {
page: RevisionPageDocument;
ancestors: AncestorRevisionPage[];
}[] = [];
for (const page of journey.pages) {
if (!page) continue;
if (!page.id) continue;
if (pageIds.includes(page.id)) continue;
pageIds.push(page.id);
const resolvedPage = resolvePageId(context.pages, page.id);
if (!resolvedPage) continue;
resolvedPages.push(resolvedPage);
}
emitted.push({
label: journey.label,
pageIds: pageIds,
});
// Deduplicate pages before yielding
const uniquePages = resolvedPages.filter((page) => {
if (allEmittedPageIds.has(page.page.id)) {
return false;
}
allEmittedPageIds.add(page.page.id);
return true;
});
yield {
label: journey.label,
icon: journey.icon,
pages: uniquePages.map((page) => ({
id: page.page.id,
title: page.page.title,
icon: page.page.icon,
emoji: page.page.emoji,
href: context.linker.toPathForPage({
pages: context.pages,
page: page.page,
}),
})),
};
}
}
}
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
import { List } from './List';
import { ListItem } from './ListItem';
import { BlockMath } from './Math';
import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
import { Paragraph } from './Paragraph';
import { Quote } from './Quote';
import { ReusableContent } from './ReusableContent';
@@ -85,6 +85,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <OpenAPIOperation {...props} block={block} />;
case 'openapi-schemas':
return <OpenAPISchemas {...props} block={block} />;
case 'openapi-webhook':
return <OpenAPIWebhook {...props} block={block} />;
case 'embed':
return <Embed {...props} block={block} />;
case 'blockquote':
@@ -159,6 +161,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'swagger':
case 'openapi-operation':
case 'openapi-schemas':
case 'openapi-webhook':
case 'math':
case 'divider':
case 'content-ref':
@@ -26,6 +26,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
const [isInViewport, setIsInViewport] = useState(false);
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted.
useEffect(() => {
@@ -77,6 +78,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
let cancelled = false;
if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => {
highlight(block, inlines).then((lines) => {
if (cancelled) {
@@ -84,6 +86,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}
setLines(lines);
setHighlighting(false);
});
});
}
@@ -98,6 +101,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}, [isInViewport, block, inlines]);
return (
<CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines ?? plainLines} />
<CodeBlockRenderer
ref={blockRef}
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
/>
);
}
@@ -24,7 +24,7 @@
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
@apply before:text-tint before:content-[counter(line)];
.highlight-line.highlighted > & {
@@ -14,6 +14,7 @@ import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
'aria-busy'?: boolean;
};
/**
@@ -23,7 +24,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>
) {
const { block, style, lines } = props;
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
@@ -31,10 +32,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const title = block.data.title;
return (
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
<div
ref={ref}
aria-busy={ariaBusy}
className={tcls('group/codeblock grid grid-flow-col', style)}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? (
<div className="inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s bg-tint px-3 py-2 text-tint text-xs leading-none tracking-wide">
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
{title}
</div>
) : null}
@@ -45,8 +50,8 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/>
<pre
className={tcls(
'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]',
'rounded-md straight-corners:rounded-sm',
'hide-scroll relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
'rounded-md straight-corners:rounded-sm shadow-sm',
title && 'rounded-ss-none'
)}
>
@@ -1,31 +1,67 @@
:root {
--shiki-color-text: theme("colors.tint.11");
--shiki-token-constant: #0a6355;
--shiki-token-string: #8b6d32;
--shiki-token-comment: theme("colors.teal.700/.64");
--shiki-token-keyword: theme("colors.pomegranate.600");
--shiki-token-parameter: #0a3069;
--shiki-token-function: #8250df;
--shiki-token-string-expression: #6a4906;
--shiki-token-punctuation: theme("colors.pomegranate.700/.92");
--shiki-token-link: theme("colors.tint.12");
--shiki-token-inserted: #22863a;
--shiki-token-deleted: #b31d28;
--shiki-token-changed: #8250df;
--shiki-token-punctuation: theme("colors.tint.11");
--shiki-token-comment: theme("colors.neutral.9/.7");
--shiki-token-link: theme("colors.primary.10");
--shiki-token-constant: theme("colors.warning.10");
--shiki-token-string: theme("colors.success.10");
--shiki-token-string-expression: theme("colors.success.10");
--shiki-token-keyword: theme("colors.danger.10");
--shiki-token-parameter: theme("colors.warning.10");
--shiki-token-function: theme("colors.primary.10");
--shiki-token-inserted: theme("colors.success.10");
--shiki-token-deleted: theme("colors.danger.10");
--shiki-token-changed: theme("colors.tint.12");
}
@media (prefers-contrast: more) {
:root {
--shiki-color-text: theme("colors.tint.12");
--shiki-token-punctuation: theme("colors.tint.12");
--shiki-token-comment: theme("colors.neutral.11");
--shiki-token-link: theme("colors.primary.11");
--shiki-token-constant: theme("colors.warning.11");
--shiki-token-string: theme("colors.success.11");
--shiki-token-string-expression: theme("colors.success.11");
--shiki-token-keyword: theme("colors.danger.11");
--shiki-token-parameter: theme("colors.warning.11");
--shiki-token-function: theme("colors.primary.11");
--shiki-token-inserted: theme("colors.success.11");
--shiki-token-deleted: theme("colors.danger.11");
--shiki-token-changed: theme("colors.tint.12");
}
}
html.dark {
--shiki-color-text: theme("colors.tint.11");
--shiki-token-constant: #d19a66;
--shiki-token-string: theme("colors.pomegranate.300");
--shiki-token-comment: theme("colors.teal.300/.64");
--shiki-token-keyword: theme("colors.pomegranate.400");
--shiki-token-parameter: theme("colors.yellow.500");
--shiki-token-function: #56b6c2;
--shiki-token-string-expression: theme("colors.tint.11");
--shiki-token-punctuation: #acc6ee;
--shiki-token-link: theme("colors.pomegranate.400");
--shiki-token-inserted: #85e89d;
--shiki-token-deleted: #fdaeb7;
--shiki-token-changed: #56b6c2;
/* Override select colors to have more contrast */
--shiki-token-comment: theme("colors.neutral.9");
--shiki-token-constant: theme("colors.warning.11");
--shiki-token-string: theme("colors.success.11");
--shiki-token-string-expression: theme("colors.success.11");
--shiki-token-keyword: theme("colors.danger.11");
--shiki-token-parameter: theme("colors.warning.11");
--shiki-token-function: theme("colors.primary.11");
}
.code-monochrome {
--shiki-token-constant: theme("colors.tint.11");
--shiki-token-string: theme("colors.tint.12");
--shiki-token-string-expression: theme("colors.tint.12");
--shiki-token-keyword: theme("colors.primary.10");
--shiki-token-parameter: theme("colors.tint.9");
--shiki-token-function: theme("colors.primary.9");
}
html.dark.code-monochrome {
--shiki-token-constant: theme("colors.tint.11");
--shiki-token-string: theme("colors.tint.12");
--shiki-token-string-expression: theme("colors.tint.12");
--shiki-token-keyword: theme("colors.primary.11");
--shiki-token-parameter: theme("colors.tint.10");
--shiki-token-function: theme("colors.primary.10");
}
@@ -1,6 +1,7 @@
import type {
DocumentInline,
DocumentInlineAnnotation,
DocumentInlineButton,
DocumentInlineEmoji,
DocumentInlineImage,
DocumentInlineLink,
@@ -13,6 +14,7 @@ import assertNever from 'assert-never';
import { Annotation } from './Annotation/Annotation';
import type { DocumentContextProps } from './DocumentView';
import { Emoji } from './Emoji';
import { InlineButton } from './InlineButton';
import { InlineImage } from './InlineImage';
import { InlineLink } from './InlineLink';
import { InlineMath } from './Math';
@@ -44,7 +46,8 @@ export function Inline<
| DocumentInlineEmoji
| DocumentInlineLink
| DocumentInlineMath
| DocumentInlineMention,
| DocumentInlineMention
| DocumentInlineButton,
>(props: InlineProps<T>) {
const { inline, ...contextProps } = props;
@@ -61,6 +64,8 @@ export function Inline<
return <Mention {...contextProps} inline={inline} />;
case 'inline-image':
return <InlineImage {...contextProps} inline={inline} />;
case 'button':
return <InlineButton {...contextProps} inline={inline} />;
default:
assertNever(inline);
}
@@ -0,0 +1,34 @@
import { resolveContentRef } from '@/lib/references';
import * as api from '@gitbook/api';
import { Button } from '../primitives';
import type { InlineProps } from './Inline';
export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
const { inline, context } = props;
if (!context.contentContext) {
throw new Error('InlineButton requires a contentContext');
}
const resolved = await resolveContentRef(inline.data.ref, context.contentContext);
if (!resolved) {
return null;
}
return (
<Button
href={resolved.href}
label={inline.data.label}
// TODO: use a variant specifically for user-defined buttons.
variant={inline.data.kind}
insights={{
type: 'link_click',
link: {
target: inline.data.ref,
position: api.SiteInsightsLinkPosition.Content,
},
}}
/>
);
}
@@ -20,6 +20,8 @@ export const contentKitServerContext: ContentKitServerContext = {
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
eye: (props) => <Icon icon="eye" {...props} />,
lock: (props) => <Icon icon="lock" {...props} />,
check: (props) => <Icon icon="check" {...props} />,
'check-circle': (props) => <Icon icon="check-circle" {...props} />,
},
codeBlock: (props) => {
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;

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