Compare commits

...

27 Commits

Author SHA1 Message Date
Samy Pessé 730771beb3 Try longer 2025-04-09 23:24:28 +02:00
Samy Pessé aaf901c9e1 Again 2025-04-09 23:03:51 +02:00
Samy Pessé 7b00d34342 Fix condition 2025-04-09 20:34:23 +02:00
Samy Pessé 957d1e30e6 Fix condition 2025-04-09 19:50:12 +02:00
Samy Pessé 93f467cf0f Fix assets URLs when base finishes with a slash 2025-04-09 19:34:27 +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
66 changed files with 1321 additions and 646 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Cache AI Page Link summary
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Tweak footer design (and refactor)
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Fix incoming URL for requests that were proxied
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add superscript and subscript text rendering
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add support for buttons to GitBook.
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI codesample (add OpenAPISelect component)
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix ogimage generation crashing when site is using a custom WOFF2 font
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Handle optional security headers
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Override Scalar's overscroll-behavior
@@ -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"
@@ -65,7 +71,7 @@ runs:
workingDirectory: ./
wranglerVersion: '3.112.0'
environment: ${{ inputs.environment }}
command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.jsonc
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:
+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: 15
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/')
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/')
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
+2
View File
@@ -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 }}"
+2
View File
@@ -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 }}"
+73 -32
View File
@@ -6,7 +6,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.4.4",
"turbo": "^2.5.0",
"vercel": "^39.3.0",
},
},
@@ -154,7 +154,7 @@
"warn-once": "^0.1.1",
},
"devDependencies": {
"@opennextjs/cloudflare": "^1.0.0-beta.0",
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@7b3b305",
"@types/rison": "^0.0.9",
"gitbook": "*",
"postcss": "^8",
@@ -196,13 +196,6 @@
"typescript": "^5.5.3",
},
},
"packages/proxy": {
"name": "@gitbook/proxy",
"version": "0.1.0",
"devDependencies": {
"typescript": "^5.5.3",
},
},
"packages/react-contentkit": {
"name": "@gitbook/react-contentkit",
"version": "0.7.0",
@@ -265,7 +258,7 @@
},
"overrides": {
"@codemirror/state": "6.4.1",
"@gitbook/api": "0.108.0",
"@gitbook/api": "0.109.0",
"react": "18.3.1",
"react-dom": "18.3.1",
},
@@ -626,7 +619,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.108.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-OytauuT3IMf0zl6rBvIacQ7YphzNhpuNAvEVQbysCGo3BhGYD1xv+vXVmtkAGUnFEAclw2fg2VjWPt3sOlQsAg=="],
"@gitbook/api": ["@gitbook/api@0.109.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-nzhxmiNR2MMHh+HoU7fFdOJ+u4BFyXjtsVquQf3VhZMjeI71yeLx5JEbjkOcijVd/GMAtvpYlYR2qcPqzq27gw=="],
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
@@ -642,8 +635,6 @@
"@gitbook/openapi-parser": ["@gitbook/openapi-parser@workspace:packages/openapi-parser"],
"@gitbook/proxy": ["@gitbook/proxy@workspace:packages/proxy"],
"@gitbook/react-contentkit": ["@gitbook/react-contentkit@workspace:packages/react-contentkit"],
"@gitbook/react-math": ["@gitbook/react-math@workspace:packages/react-math"],
@@ -676,6 +667,8 @@
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
@@ -792,9 +785,9 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@opennextjs/aws": ["@opennextjs/aws@3.5.4", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.19.2", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-QPCTcJ95p0pHUVDjjiB9MGRbOKotaq1Dl9WrmS6/dJt6vb6zdavKFBYdT/WQsTn5wj2RqInim7t6Xc5x+wAO6Q=="],
"@opennextjs/aws": ["@opennextjs/aws@3.5.5", "", { "dependencies": { "@ast-grep/napi": "^0.35.0", "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.19.2", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0", "yaml": "^2.7.0" }, "bin": { "open-next": "dist/index.js" } }, "sha512-vIqCcNxy7vPdC5HQQkCN+AXhxdNvagZ8EYw5XvuYIAP2T1Ci9ai/8m6UXtjgd3L9+aBA8pbWfr2Wr/BNLX5m2Q=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.0.0-beta.0", "", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.5.4", "enquirer": "^2.4.1", "glob": "^11.0.0" }, "peerDependencies": { "wrangler": "^3.114.3 || ^4.7.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-hBYUs/v5MHkXqZnKn9rrXnjlf/dgUOhMoaqPHcrc8eS24GC7AFuQvDH1a4v7y54xzF2/rOJVYaq6RcbDQAve+A=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@7b3b305", { "dependencies": { "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.5.5", "enquirer": "^2.4.1", "glob": "^11.0.0", "ts-tqdm": "^0.8.6" }, "peerDependencies": { "wrangler": "^3.114.3 || ^4.7.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2788,21 +2781,23 @@
"ts-toolbelt": ["ts-toolbelt@6.15.5", "", {}, "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A=="],
"ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="],
"tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="],
"turbo": ["turbo@2.4.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.4.4", "turbo-darwin-arm64": "2.4.4", "turbo-linux-64": "2.4.4", "turbo-linux-arm64": "2.4.4", "turbo-windows-64": "2.4.4", "turbo-windows-arm64": "2.4.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-N9FDOVaY3yz0YCOhYIgOGYad7+m2ptvinXygw27WPLQvcZDl3+0Sa77KGVlLSiuPDChOUEnTKE9VJwLSi9BPGQ=="],
"turbo": ["turbo@2.5.0", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.0", "turbo-darwin-arm64": "2.5.0", "turbo-linux-64": "2.5.0", "turbo-linux-arm64": "2.5.0", "turbo-windows-64": "2.5.0", "turbo-windows-arm64": "2.5.0" }, "bin": { "turbo": "bin/turbo" } }, "sha512-PvSRruOsitjy6qdqwIIyolv99+fEn57gP6gn4zhsHTEcCYgXPhv6BAxzAjleS8XKpo+Y582vTTA9nuqYDmbRuA=="],
"turbo-darwin-64": ["turbo-darwin-64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5kPvRkLAfmWI0MH96D+/THnDMGXlFNmjeqNRj5grLKiry+M9pKj3pRuScddAXPdlxjO5Ptz06UNaOQrrYGTx1g=="],
"turbo-darwin-64": ["turbo-darwin-64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fP1hhI9zY8hv0idym3hAaXdPi80TLovmGmgZFocVAykFtOxF+GlfIgM/l4iLAV9ObIO4SUXPVWHeBZQQ+Hpjag=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/gtHPqbGQXDFhrmy+Q/MFW2HUTUlThJ97WLLSe4bxkDrKHecDYhAjbZ4rN3MM93RV9STQb3Tqy4pZBtsd4DfCw=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p9sYq7kXH7qeJwIQE86cOWv/xNqvow846l6c/qWc26Ib1ci5W7V0sI5thsrP3eH+VA0d+SHalTKg5SQXgNQBWA=="],
"turbo-linux-64": ["turbo-linux-64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-SR0gri4k0bda56hw5u9VgDXLKb1Q+jrw4lM7WAhnNdXvVoep4d6LmnzgMHQQR12Wxl3KyWPbkz9d1whL6NTm2Q=="],
"turbo-linux-64": ["turbo-linux-64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1iEln2GWiF3iPPPS1HQJT6ZCFXynJPd89gs9SkggH2EJsj3eRUSVMmMC8y6d7bBbhBFsiGGazwFIYrI12zs6uQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-COXXwzRd3vslQIfJhXUklgEqlwq35uFUZ7hnN+AUyXx7hUOLIiD5NblL+ETrHnhY4TzWszrbwUMfe2BYWtaPQg=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-bKBcbvuQHmsX116KcxHJuAcppiiBOfivOObh2O5aXNER6mce7YDDQJy00xQQNp1DhEfcSV2uOsvb3O3nN2cbcA=="],
"turbo-windows-64": ["turbo-windows-64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-PV9rYNouGz4Ff3fd6sIfQy5L7HT9a4fcZoEv8PKRavU9O75G7PoDtm8scpHU10QnK0QQNLbE9qNxOAeRvF0fJg=="],
"turbo-windows-64": ["turbo-windows-64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-9BCo8oQ7BO7J0K913Czbc3tw8QwLqn2nTe4E47k6aVYkM12ASTScweXPTuaPFP5iYXAT6z5Dsniw704Ixa5eGg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-403sqp9t5sx6YGEC32IfZTVWkRAixOQomGYB8kEc6ZD+//LirSxzeCHCnM8EmSXw7l57U1G+Fb0kxgTcKPU/Lg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OUHCV+ueXa3UzfZ4co/ueIHgeq9B2K48pZwIxKSm5VaLVuv8M13MhM7unukW09g++dpdrrE1w4IOVgxKZ0/exg=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
@@ -4022,7 +4017,7 @@
"gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"gitbook-v2/next": ["next@15.3.0-canary.37", "", { "dependencies": { "@next/env": "15.3.0-canary.37", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.0-canary.37", "@next/swc-darwin-x64": "15.3.0-canary.37", "@next/swc-linux-arm64-gnu": "15.3.0-canary.37", "@next/swc-linux-arm64-musl": "15.3.0-canary.37", "@next/swc-linux-x64-gnu": "15.3.0-canary.37", "@next/swc-linux-x64-musl": "15.3.0-canary.37", "@next/swc-win32-arm64-msvc": "15.3.0-canary.37", "@next/swc-win32-x64-msvc": "15.3.0-canary.37", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-K9BJZoFYnp+WqR7jYSCzrBJHOwTXJOED964z0En7qszrjF2HXfa24W2bFAMBwK56JD+h4lUyhpbgBoDiDN+vnA=="],
"gitbook-v2/next": ["next@15.3.0-canary.43", "", { "dependencies": { "@next/env": "15.3.0-canary.43", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.0-canary.43", "@next/swc-darwin-x64": "15.3.0-canary.43", "@next/swc-linux-arm64-gnu": "15.3.0-canary.43", "@next/swc-linux-arm64-musl": "15.3.0-canary.43", "@next/swc-linux-x64-gnu": "15.3.0-canary.43", "@next/swc-linux-x64-musl": "15.3.0-canary.43", "@next/swc-win32-arm64-msvc": "15.3.0-canary.43", "@next/swc-win32-x64-msvc": "15.3.0-canary.43", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-am6xpZIx2P0VJ26N7K2CImmznYUP65XS0e0nkYtypWf/RiMsScwmCqrA4qrEK9u/tiPlA+583IcQPos9yKLg1Q=="],
"global-dirs/ini": ["ini@1.3.7", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="],
@@ -4890,26 +4885,28 @@
"gaxios/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"gitbook-v2/next/@next/env": ["@next/env@15.3.0-canary.37", "", {}, "sha512-NoG8pEk34zpIphk7vSTZ6zs3rj33jtxWO5E2o+bCE8gjFQunChNnRWP1OPo2cATmnc64aJKcvdX5uOuEtAvoUw=="],
"gitbook-v2/next/@next/env": ["@next/env@15.3.0-canary.43", "", {}, "sha512-ogryUGufxhtidxr/42NY6+ADlvxt9Hq6Q/DOMS/20vGuePZ70wO6Z5m1RP3Q397RGgpe1gSWyYdlg+Mt7H4KeQ=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.0-canary.37", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H+gorYP1jSJCmPDpTlQIyLS6GXkA6WZ74H7lxHeXWdAWyP7zv/MmxuhPmsD/RFzYEwZNDSk9jVFpYcOvfWK1Hw=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.0-canary.43", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eXrs8CyIBgdnMTNjku+h/xm61gmRCcKAT+tM2CjpEXbEqXBux5hRIakOk5kJJDu2fA2P3pzQGt5PRD1hg4srXA=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.0-canary.37", "", { "os": "darwin", "cpu": "x64" }, "sha512-zul0GsE7SGga8DdBWHRYzdQC5WST1RMmVJRxutvqCqcm7R/GosIzibrEFE5AkW7XMh6bZCO71r4zKPehTuWFcg=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.0-canary.43", "", { "os": "darwin", "cpu": "x64" }, "sha512-iRGvblEh/b2grxkkp9pT+yea9EzGNM4tLyUZoCzkejkU2jMLsn2DH6h3bQwCfEYZL3YFGsYmVISrVCOVi8LeMw=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.0-canary.37", "", { "os": "linux", "cpu": "arm64" }, "sha512-ucAOG0sMG2MkNbGTFAI0qncbkci0VE34xjwcpxUT7LUv/rWG83oVOcxcGY5xVbz9V6MpPCxr62FV3I5h6t+w0w=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.0-canary.43", "", { "os": "linux", "cpu": "arm64" }, "sha512-IqknHGNxpL03uIutIuv7FPjGHuD/AnJVC5exi5g+C7P3f6JVvOjFLS264eqi91tVCXhN2LpcKNGwTlK81bJVVg=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.0-canary.37", "", { "os": "linux", "cpu": "arm64" }, "sha512-hVnST5YroTumu1y+UB4HXN2mbmTs5VWqsr8KmQonAc7+Uu9hvjSb79cpCD5B97h0TBb34Z2vr90hN9w0Q06PFA=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.0-canary.43", "", { "os": "linux", "cpu": "arm64" }, "sha512-FbO0dnFsNe3f1LWTn4vyXTWTSrZdwNRnURYExSQ+0AINHphNfwKQNrPqVLrapQ9CAOCOz8R5p9Kf1++IsH3JJQ=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.0-canary.37", "", { "os": "linux", "cpu": "x64" }, "sha512-p7ArUaiTRm5pSqHg60UtJbZUzsOU90idibVRPgnXd+0Kec7n6TClE3pWcIFow9by0bpSi6kmbNmkHf61i0Kerw=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.0-canary.43", "", { "os": "linux", "cpu": "x64" }, "sha512-MAaLEm8eO6Xir3YC3xLYDHDjLGogAAGRrxhuflvaqAtGQZ6NIMG4YjvAyramYTq/SwrUIDobggKxdQLtu8/pPQ=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.0-canary.37", "", { "os": "linux", "cpu": "x64" }, "sha512-Egi3swoN3pbseJ3oRGFYs94ICEjmzmY+exWfNlpycjBMWp1m/QzIcIxHTdz2/Lqe5SNGVMrJ8uEDNAD2knqgvw=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.0-canary.43", "", { "os": "linux", "cpu": "x64" }, "sha512-gdwF79/EQjY3zgcolO0jlDe0yfII9tXyXQeqL+uvzA8gZT5FpH0KkwSWzxj8EUswWzZcprbDa87sq8H0Eo+whw=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.0-canary.37", "", { "os": "win32", "cpu": "arm64" }, "sha512-gOE0lBeS/ztrSHnXimEp9D5OOTN86moSfMgC05ei/UJSFyT7YEZa4FRYRDzctNEilRJC2DBnxjEpeD+JhDuf6A=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.0-canary.43", "", { "os": "win32", "cpu": "arm64" }, "sha512-5WYne3jvo1478kUfe901wFxvPMdC8tRKundKIgU5Upe1HafMMS7ymm1hQ7CUpp3/1vY/R1TV1oKHHJfqDubiNg=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.0-canary.37", "", { "os": "win32", "cpu": "x64" }, "sha512-J/SjJYN1Nj/olRC0ykfDNKKoxJss4a3Zf340UeyHbmbqeKHvGbT/H0QFMx+3DT+Sqar3PqbyZBLbxpE/QYct1w=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.0-canary.43", "", { "os": "win32", "cpu": "x64" }, "sha512-xE3WZhwjb91eezecVsmXn/OtdISfMsIfS3t0ZXsS/+bMvO/LZLdcVBtl0Zy5yR+XJyKfXXmwpdYbL6WH4dGuQg=="],
"gitbook-v2/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"gitbook-v2/next/sharp": ["sharp@0.34.1", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.7.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.1", "@img/sharp-darwin-x64": "0.34.1", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.1", "@img/sharp-linux-arm64": "0.34.1", "@img/sharp-linux-s390x": "0.34.1", "@img/sharp-linux-x64": "0.34.1", "@img/sharp-linuxmusl-arm64": "0.34.1", "@img/sharp-linuxmusl-x64": "0.34.1", "@img/sharp-wasm32": "0.34.1", "@img/sharp-win32-ia32": "0.34.1", "@img/sharp-win32-x64": "0.34.1" } }, "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg=="],
"gitbook-v2/next/styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"globby/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -5126,6 +5123,46 @@
"gitbook-v2/next/postcss/nanoid": ["nanoid@3.3.7", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="],
"gitbook-v2/next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A=="],
"gitbook-v2/next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="],
"gitbook-v2/next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="],
"gitbook-v2/next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA=="],
"gitbook-v2/next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ=="],
"gitbook-v2/next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA=="],
"gitbook-v2/next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA=="],
"gitbook-v2/next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ=="],
"gitbook-v2/next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.1", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg=="],
"gitbook-v2/next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.1", "", { "dependencies": { "@emnapi/runtime": "^1.4.0" }, "cpu": "none" }, "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg=="],
"gitbook-v2/next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw=="],
"gitbook-v2/next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw=="],
"gitbook-v2/next/sharp/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -5168,6 +5205,8 @@
"@vercel/nft/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"gitbook-v2/next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.4.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw=="],
"@aws-sdk/core/@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg=="],
"@aws-sdk/core/@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg=="],
@@ -5175,5 +5214,7 @@
"@aws-sdk/middleware-sdk-sqs/@smithy/smithy-client/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg=="],
"@aws-sdk/middleware-sdk-sqs/@smithy/smithy-client/@smithy/util-stream/@smithy/node-http-handler/@smithy/querystring-builder/@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg=="],
"gitbook-v2/next/sharp/@img/sharp-wasm32/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
+2 -2
View File
@@ -4,7 +4,7 @@
"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",
@@ -12,7 +12,7 @@
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.108.0"
"@gitbook/api": "0.109.0"
},
"private": true,
"scripts": {
+8
View File
@@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["worker-configuration.d.ts"]
}
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import kvIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache';
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,
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
tagCache: doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
+1 -1
View File
@@ -16,7 +16,7 @@
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^1.0.0-beta.0",
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@7b3b305",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
+3 -2
View File
@@ -531,7 +531,7 @@ const getRevisionPageByPath = withCacheKey(
) => {
const uncached = unstable_cache(
async () => getRevisionPageByPathUncached(cacheKey, input, params),
[cacheKey],
[cacheKey, 'v2'],
{
revalidate: RevalidationProfile.max,
tags: [],
@@ -551,12 +551,13 @@ const getRevisionPageByPathUncached = withoutConcurrentExecution(
return trace(
`getRevisionPageByPath.uncached(${params.spaceId}, ${params.revisionId}, ${params.path})`,
async () => {
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
params.path,
encodedPath,
{}
);
return res.data;
@@ -4,3 +4,5 @@ export * from './pages';
export * from './urls';
export * from './errors';
export * from './lookup';
export * from './proxy';
export * from './visitor';
@@ -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('/');
}
@@ -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;
}
@@ -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;
@@ -34,7 +34,9 @@ export async function verifyImageSignature(
const generated = await generator(input);
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
console.log(`comparing image signature "${generated}" (expected) === "${signature}" (actual)`);
console.log(
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
);
return generated === signature;
}
@@ -69,7 +71,8 @@ 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
+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,
+50 -33
View File
@@ -10,33 +10,8 @@
"observability": {
"enabled": true
},
"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"]
}
],
"env": {
"preview": {
"kv_namespaces": [
{
// gitbook-open-v2-cache-preview
"binding": "NEXT_INC_CACHE_KV",
"id": "b7dd9cf58bf2458f84812a2d83b3760c"
}
],
"d1_databases": [
{
"binding": "NEXT_CACHE_D1",
@@ -44,6 +19,12 @@
"database_name": "gitbook-open-v2-tags-preview"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
@@ -54,6 +35,8 @@
"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": [
@@ -66,11 +49,10 @@
"zone_name": "gitbook-staging.com"
}
],
"kv_namespaces": [
"r2_buckets": [
{
// gitbook-open-v2-cache-staging
"binding": "NEXT_INC_CACHE_KV",
"id": "a446e25f12b741afb185f1e5b4474f0a"
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"d1_databases": [
@@ -94,6 +76,24 @@
{
"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": {
@@ -107,11 +107,10 @@
"zone_name": "gitbook.com"
}
],
"kv_namespaces": [
"r2_buckets": [
{
// gitbook-open-v2-cache-production
"binding": "NEXT_INC_CACHE_KV",
"id": "72379746280d4e79acf24440eea950dc"
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"d1_databases": [
@@ -135,6 +134,24 @@
{
"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"]
}
]
}
}
+9 -5
View File
@@ -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',
@@ -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);
+51
View File
@@ -419,6 +419,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 +874,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/',
+27 -13
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();
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
}
}
});
}
+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) {
@@ -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>
@@ -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,
},
}}
/>
);
}
@@ -1,5 +1,11 @@
@import "@scalar/api-client-react/style.css";
html,
body {
/** Override Scalar's overscroll-behavior */
@apply !overscroll-auto;
}
.light .scalar-modal-layout,
.light .scalar-app,
.light .scalar {
@@ -90,7 +90,7 @@
/* Method Tags */
.openapi-method {
@apply rounded uppercase font-mono font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
@apply rounded uppercase font-mono shrink-0 font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
}
.openapi-method-get {
@@ -423,8 +423,20 @@
@apply flex flex-row items-center;
}
.openapi-codesample-header .openapi-select > button {
@apply border-none;
}
.openapi-codesample-header-content {
@apply flex flex-row items-center h-fit;
@apply flex flex-row items-center justify-between h-fit p-2.5;
}
.openapi-codesample-header-content .openapi-path {
@apply flex items-center font-mono *:text-[0.813rem] gap-2 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
}
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
@apply text-[0.813rem];
}
.openapi-codesample-footer {
@@ -437,7 +449,7 @@
/* Path */
.openapi-path {
@apply flex items-start text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
@apply flex items-center text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -451,12 +463,12 @@
}
.openapi-path .openapi-method {
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-2;
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-1;
}
.openapi-path-title {
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
@apply whitespace-nowrap md:whitespace-normal;
}
@@ -468,14 +480,6 @@
display: none;
}
/* .openapi-path-copy {
@apply absolute opacity-0 h-fit right-0 top-1/2 -translate-y-1/2 bg-light dark:bg-dark border rounded-md border-tint-subtle px-1.5 py-0;
}
.openapi-path-title:hover .openapi-path-copy {
@apply opacity-11;
} */
.openapi-path-title em {
@apply not-italic text-primary font-medium;
}
@@ -502,7 +506,8 @@
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
.openapi-panel-footer {
.openapi-panel-footer,
.openapi-codesample-footer {
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
}
@@ -517,7 +522,57 @@
/* Common Elements */
.openapi-select {
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
/* unstyled */
}
/* Prevent react-aria popover from setting overflow:auto on body */
body:has(.openapi-select-popover) {
overflow: unset !important;
}
.openapi-select > button {
@apply flex items-center cursor-pointer gap-1.5 text-tint-strong max-w-60 rounded text-xs leading-6 px-1.5 truncate border border-tint-subtle bg-tint;
@apply hover:bg-tint-hover transition-all;
}
.openapi-select > button > span.react-aria-SelectValue {
@apply shrink truncate;
}
.openapi-select > button > .gb-icon {
@apply shrink-0;
}
.openapi-select > button svg {
@apply size-2.5;
}
.openapi-select-popover {
@apply min-w-32 max-w-fit w-auto max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md;
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
}
.openapi-select-popover[data-entering] {
animation: popover-enter 0.2s ease-in-out;
}
.openapi-select-popover[data-exiting] {
animation: popover-leave 0.2s ease-in-out;
}
.openapi-select-item {
@apply text-sm cursor-pointer px-1.5 py-0.5 truncate text-tint ring-0 border-none rounded !outline-none;
@apply hover:bg-tint-hover theme-gradient:hover:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
}
.openapi-select-item-selected {
@apply text-primary-subtle hover:text-primary hover:bg-primary-hover;
@apply theme-muted:hover:bg-primary-active theme-gradient:hover:bg-primary-active tint:font-semibold;
@apply contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:font-semibold;
}
.openapi-select-listbox {
@apply flex flex-col gap-1 focus:ring-0 focus:outline-none;
}
.openapi-select:focus {
@@ -580,8 +635,10 @@
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
}
.openapi-tabs-panel {
.openapi-tabs-panel,
.openapi-codesample-panel {
@apply flex-1 text-sm relative focus-visible:outline-none;
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
}
/* Disclosure group */
@@ -731,6 +788,32 @@
}
}
.openapi-copy-button {
@apply hover:brightness-95;
@keyframes popover-enter {
0% {
opacity: 0;
transform: translateY(4px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes popover-leave {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(4px) scale(0.95);
}
}
.openapi-copy-button {
@apply hover:brightness-95 cursor-pointer;
}
.openapi-copy-button[data-disabled="true"] {
@apply cursor-default;
}
@@ -5,6 +5,8 @@ import type {
DocumentMarkItalic,
DocumentMarkKeyboard,
DocumentMarkStrikethrough,
DocumentMarkSubscript,
DocumentMarkSuperscript,
DocumentText,
DocumentTextMark,
} from '@gitbook/api';
@@ -47,6 +49,8 @@ const MARK_STYLES = {
strikethrough: Strikethrough,
color: Color,
keyboard: Keyboard,
superscript: Superscript,
subscript: Subscript,
};
interface MarkedLeafProps<Mark extends DocumentTextMark> {
@@ -74,6 +78,14 @@ function Keyboard(props: MarkedLeafProps<DocumentMarkKeyboard>) {
);
}
function Superscript(props: MarkedLeafProps<DocumentMarkSuperscript>) {
return <sup>{props.children}</sup>;
}
function Subscript(props: MarkedLeafProps<DocumentMarkSubscript>) {
return <sub>{props.children}</sub>;
}
function Code(props: MarkedLeafProps<DocumentMarkCode>) {
return (
<code
+101 -101
View File
@@ -15,123 +15,123 @@ export function Footer(props: { context: GitBookSiteContext }) {
const { context } = props;
const { customization } = context;
const hasLogo = customization.footer.logo;
const groupCount = customization.footer.groups?.length ?? 0;
const hasGroups = groupCount > 0;
const hasCopyright = customization.footer.copyright;
const hasThemeToggle = customization.themes.toggeable;
const mobileOnly = !hasLogo && !hasGroups && !hasCopyright && hasThemeToggle;
return (
<footer
className={tcls(
'border-tint-subtle border-t',
// If the footer only contains a mode toggle, we only show it on smaller screens
customization.themes.toggeable &&
!customization.footer.copyright &&
!customization.footer.logo &&
customization.footer.groups?.length === 0
? 'xl:hidden'
: null
mobileOnly ? 'xl:hidden' : null
)}
>
<div className="scroll-nojump">
<div className={tcls(CONTAINER_STYLE, 'px-4', 'mx-auto', 'flex', 'gap-12')}>
<div className="hidden page-no-toc:hidden basis-72 lg:block" />
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
<div
className={tcls(
'py-8',
'gap-12',
'flex',
'flex-wrap',
'items-start',
'w-full',
'flex-1',
'max-w-3xl',
'page-full-width:max-w-none',
'mx-auto'
'mx-auto grid max-w-3xl justify-between gap-12 lg:max-w-none',
'grid-cols-[auto_auto]',
'lg:grid-cols-[18rem_minmax(auto,_48rem)_auto]',
'xl:grid-cols-[18rem_minmax(auto,_48rem)_14rem]',
'page-no-toc:lg:grid-cols-[minmax(auto,_48rem)_auto]',
'page-no-toc:xl:grid-cols-[14rem_minmax(auto,_48rem)_14rem]'
)}
>
{/* Footer Logo */}
{customization.footer.logo ? (
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.footer.logo.light,
},
dark: customization.footer.logo.dark
? {
src: customization.footer.logo.dark,
}
: null,
}}
priority="lazy"
style={[
'w-auto',
'max-w-40',
'lg:max-w-64',
'max-h-12',
'object-contain',
'object-left',
'rounded',
'straight-corners:rounded-sm',
'order-1',
]}
sizes={[
{
width: 320,
},
]}
/>
) : null}
{
// Footer Logo
customization.footer.logo ? (
<div className="col-start-1 row-start-1">
<Image
alt="Logo"
resize={context.imageResizer}
sources={{
light: {
src: customization.footer.logo.light,
},
dark: customization.footer.logo.dark
? {
src: customization.footer.logo.dark,
}
: null,
}}
priority="lazy"
style={[
'w-auto',
'max-w-40',
'lg:max-w-64',
'max-h-10',
'lg:max-h-12',
'object-contain',
'object-left',
'rounded',
'straight-corners:rounded-sm',
]}
sizes={[
{
width: 320,
},
]}
/>
</div>
) : null
}
{/* Mode Switcher */}
{customization.themes.toggeable ? (
<div className="order-2 ml-auto flex items-center justify-end xl:hidden">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
</div>
) : null}
{
// Theme Toggle
customization.themes.toggeable ? (
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden">
<React.Suspense fallback={null}>
<ThemeToggler />
</React.Suspense>
</div>
) : null
}
{/* Navigation Groups (split into equal columns) */}
{customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'order-3 mx-auto flex w-full grow flex-col gap-10 sm:flex-row sm:gap-6',
!customization.footer.logo &&
customization.footer.groups.length < 2 &&
'self-center sm:order-1 sm:w-auto sm:max-w-3xl sm:flex-1 sm:items-start sm:text-start'
)}
>
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
key={columnIndex}
className="flex w-full flex-col gap-10"
>
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
)
)}
</div>
) : null}
{
// Navigation groups (split into equal columns)
customization.footer.groups?.length > 0 ? (
<div
className={tcls(
'col-span-2 page-has-toc:lg:col-span-1 page-has-toc:lg:col-start-2 page-no-toc:xl:col-span-1 page-no-toc:xl:col-start-2'
)}
>
<div className="mx-auto flex max-w-3xl flex-col gap-10 sm:flex-row sm:gap-6">
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
(column, columnIndex) => (
<div
key={columnIndex}
className="flex flex-1 grow flex-col gap-10"
>
{column.map((group, groupIndex) => (
<FooterLinksGroup
key={groupIndex}
group={group}
context={context}
/>
))}
</div>
)
)}
</div>
</div>
) : null
}
{/* Legal */}
<div
className={tcls(
'order-4 mx-auto flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs empty:hidden',
customization.footer.groups.length === 0 &&
'self-center sm:order-1 sm:w-auto sm:max-w-3xl sm:flex-1 sm:items-start sm:text-start'
)}
>
{customization.footer.copyright ? (
<p>{customization.footer.copyright}</p>
) : null}
</div>
{
// Legal
customization.footer.copyright ? (
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs">
<p>{customization.footer.copyright}</p>
</div>
) : null
}
</div>
<div className="hidden page-no-toc:hidden lg:block xl:basis-56" />
</div>
</div>
</footer>
@@ -58,6 +58,7 @@ export function Dropdown<E extends HTMLElement>(props: {
className={tcls(
'mt-2',
'w-full',
'max-h-80',
'bg-tint-base',
'rounded-lg',
'straight-corners:rounded-sm',
@@ -39,7 +39,7 @@ export function SpaceLayout(props: {
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withSections = Boolean(sections && sections.list.length > 0);
const withSections = Boolean(sections && sections.list.length > 1);
const isMultiVariants = Boolean(siteSpaces.length > 1);
const withFooter =
@@ -50,7 +50,7 @@ export const Link = React.forwardRef(function Link(
// When the page is embedded in an iframe, for security reasons other urls cannot be opened.
// In this case, we open the link in a new tab.
if (isExternal && window.self !== window.top) {
if (window.self !== window.top && isExternalLink(href, window.location.origin)) {
event.preventDefault();
window.open(href, '_blank');
}
@@ -58,7 +58,9 @@ export const Link = React.forwardRef(function Link(
domProps.onClick?.(event);
};
if (isExternal) {
// We test if the link is external, without comparing to the origin
// as this will be rendered on the server and it could result in a mismatch.
if (isExternalLink(href)) {
return (
<a ref={ref} {...domProps} href={href} onClick={onClick}>
{children}
@@ -72,3 +74,27 @@ export const Link = React.forwardRef(function Link(
</NextLink>
);
});
/**
* Check if a link is external, compared to an origin.
*/
function isExternalLink(href: string, origin: string | null = null) {
if (!URL.canParse) {
// If URL.canParse is not available, we quickly check if it looks like a URL
return href.startsWith('http');
}
if (!URL.canParse(href)) {
// If we can't parse the href, we consider it a relative path
return false;
}
if (!origin) {
// If origin is not provided, we consider the link external
return true;
}
// If the url points to the same origin, we consider it internal
const parsed = new URL(href);
return parsed.origin !== origin;
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { GITBOOK_ASSETS_URL } from '@v2/lib/env';
import { GITBOOK_ASSETS_URL, GITBOOK_URL } from '@v2/lib/env';
import { joinPathWithBaseURL } from './paths';
/**
* Create a public URL for an asset.
*/
export function getAssetURL(path: string): string {
return `${GITBOOK_ASSETS_URL || ''}/~gitbook/static/${path}`;
return joinPathWithBaseURL(GITBOOK_ASSETS_URL || GITBOOK_URL, `~gitbook/static/${path}`);
}
+9
View File
@@ -3,6 +3,7 @@ import { ImageResponse } from 'next/og';
import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind';
import { getCacheTag } from '@gitbook/cache-tags';
import type { GitBookSiteContext } from '@v2/lib/context';
import { getResizedImageURL } from '@v2/lib/images';
@@ -73,6 +74,14 @@ export async function serveIcon(context: GitBookSiteContext, req: Request) {
{
width: size.width,
height: size.height,
headers: {
'cache-tag': [
getCacheTag({
tag: 'site',
site: context.site.id,
}),
].join(','),
},
}
);
}
+87 -6
View File
@@ -7,7 +7,9 @@ import { type PageParams, fetchPageData } from '@/components/SitePage';
import { getFontSourcesToPreload } from '@/fonts/custom';
import { getAssetURL } from '@/lib/assets';
import { filterOutNullable } from '@/lib/typescript';
import { getCacheTag } from '@gitbook/cache-tags';
import type { GitBookSiteContext } from '@v2/lib/context';
import { getCloudflareContext } from '@v2/lib/data/cloudflare';
import { getResizedImageURL } from '@v2/lib/images';
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
@@ -87,12 +89,15 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
await Promise.all(
primaryFontWeights.map((face) => {
const { weight, sources } = face;
if (sources.length === 0) {
const source = sources[0];
// Satori doesn't support WOFF2, so we skip it
// https://github.com/vercel/satori?tab=readme-ov-file#fonts
if (!source || source.format === 'woff2' || source.url.endsWith('.woff2')) {
return null;
}
const url = sources[0].url;
return loadCustomFont({ url, weight });
return loadCustomFont({ url: source.url, weight });
})
)
).filter(filterOutNullable);
@@ -169,8 +174,12 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
{String.fromCodePoint(Number.parseInt(`0x${customization.favicon.emoji}`))}
</span>
);
const src = linker.toAbsoluteURL(
linker.toPathInSpace(`~gitbook/icon?size=medium&theme=${customization.themes.default}`)
const src = await readSelfImage(
linker.toAbsoluteURL(
linker.toPathInSpace(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`
)
)
);
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
})();
@@ -192,7 +201,11 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
/>
{/* Grid */}
<img tw="absolute inset-0 w-[100vw] h-[100vh]" src={gridAsset} alt="Grid" />
<img
tw="absolute inset-0 w-[100vw] h-[100vh]"
src={await readStaticImage(gridAsset)}
alt="Grid"
/>
{/* Logo */}
{customization.header.logo ? (
@@ -228,6 +241,18 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
width: 1200,
height: 630,
fonts: fonts.length ? fonts : undefined,
headers: {
'cache-tag': [
getCacheTag({
tag: 'site',
site: baseContext.site.id,
}),
getCacheTag({
tag: 'space',
space: baseContext.space.id,
}),
].join(','),
},
}
);
}
@@ -285,3 +310,59 @@ async function loadCustomFont(input: { url: string; weight: 400 | 700 }) {
weight,
};
}
/**
* Fetch a resource from the function itself.
* To avoid error with worker to worker requests in the same zone, we use the `WORKER_SELF_REFERENCE` binding.
*/
async function fetchSelf(url: string) {
const cloudflare = getCloudflareContext();
if (cloudflare?.env.WORKER_SELF_REFERENCE) {
return await cloudflare.env.WORKER_SELF_REFERENCE.fetch(
// `getAssetURL` can return a relative URL, so we need to make it absolute
// the URL doesn't matter, as we're using the worker-self-reference binding
new URL(url, 'https://worker-self-reference/')
);
}
return await fetch(url);
}
/**
* Read an image from a response as a base64 encoded string.
*/
async function readImage(response: Response) {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.startsWith('image/')) {
throw new Error(`Invalid content type: ${contentType}`);
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
return `data:${contentType};base64,${base64}`;
}
const staticImagesCache = new Map<string, string>();
/**
* Read a static image and cache it in memory.
*/
async function readStaticImage(url: string) {
const cached = staticImagesCache.get(url);
if (cached) {
return cached;
}
const image = await readSelfImage(url);
staticImagesCache.set(url, image);
return image;
}
/**
* Read an image from GitBook itself.
*/
async function readSelfImage(url: string) {
const response = await fetchSelf(url);
const image = await readImage(response);
return image;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["data/*.json"]
"outputs": ["src/data/*.json", "dist/data/*.json"]
}
}
}
-1
View File
@@ -1 +0,0 @@
dist/
-7
View File
@@ -1,7 +0,0 @@
# @gitbook/proxy
## 0.1.0
### Minor Changes
- 53b9f10: First version
-28
View File
@@ -1,28 +0,0 @@
# `@gitbook/proxy`
Host a GitBook site on your own domain as a subpath.
## Usage
```ts
import { proxyToGitBook } from '@gitbook/proxy';
const site = proxyToGitBook(event.request, {
site: 'mycompany.gitbook.io/site/',
basePath: '/docs',
});
export default {
async fetch(request) {
// If the requst matches the basePath /docs, we serve from GitBook
if (site.match(request)) {
return site.fetch(request);
}
// Otherwise we do something else.
return new Response('Not found', {
statusCode: 404,
});
},
};
```
-23
View File
@@ -1,23 +0,0 @@
{
"name": "@gitbook/proxy",
"description": "Host a GitBook site on your own domain as a subpath",
"version": "0.1.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"clean": "rm -rf ./dist",
"unit": "bun test"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { proxyToGitBook } from '.';
describe('.match', () => {
it('should return true if the request is below the base path', () => {
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' });
expect(site.match('/docs')).toBe(true);
expect(site.match('/docs/')).toBe(true);
expect(site.match('/docs/hello')).toBe(true);
expect(site.match('/docs/hello/world')).toBe(true);
expect(site.match('/hello/world')).toBe(false);
expect(site.match('/')).toBe(false);
});
});
describe('.request', () => {
it('should compute a proper request for a sub-path', () => {
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' });
const request = new Request('https://example.com/docs/hello/world');
const proxiedRequest = site.request(request);
expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs/hello/world');
expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io');
expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com');
expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs');
expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe(
'https://org.gitbook.io/example/'
);
});
it('should compute a proper request on the root', () => {
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' });
const request = new Request('https://example.com/docs');
const proxiedRequest = site.request(request);
expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs');
expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io');
expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com');
expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs');
expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe(
'https://org.gitbook.io/example/'
);
});
it('should normalize the basepath', () => {
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: 'docs/' });
const request = new Request('https://example.com/docs/hello/world');
const proxiedRequest = site.request(request);
expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs/hello/world');
expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io');
expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com');
expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs');
expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe(
'https://org.gitbook.io/example/'
);
});
});
-97
View File
@@ -1,97 +0,0 @@
export interface ProxyToGitBookOptions {
/**
* The URL of the published site.
* @example "https://mycompany.gitbook.io/docs"
*/
site: string;
/**
* Base path to serve the site on.
* @example "/docs"
*/
basePath: string;
/**
* Hostname used by GitBook to serve content.
* Do not set this option unless you know what you are doing.
*/
gitbookHost?: string;
}
export interface ProxySite {
/**
* Test if the request should be proxied to this site.
*/
match(request: Request | string): boolean;
/**
* Get the proxied request for a given request.
*/
request(request: Request): Request;
/**
* Fetch the request from the site.
*/
fetch(request: Request): Promise<Response>;
}
/**
* Proxies requests to a GitBook site.
*/
export function proxyToGitBook(options: ProxyToGitBookOptions): ProxySite {
const { gitbookHost = 'hosting.gitbook.io' } = options;
const siteUrl = new URL(options.site);
const rawSiteUrl = siteUrl.toString();
const basePath = normalizeBasePath(options.basePath);
const site: ProxySite = {
match: (request) => {
const pathname = typeof request === 'string' ? request : new URL(request.url).pathname;
return pathname === basePath || pathname.startsWith(`${basePath}/`);
},
request: (originRequest) => {
const originUrl = new URL(originRequest.url);
const url = new URL(originUrl);
url.hostname = gitbookHost;
const proxyRequest = new Request(url, originRequest);
proxyRequest.headers.set('Host', gitbookHost);
// Pass the original host and protocol
proxyRequest.headers.set('X-Forwarded-Host', originUrl.hostname);
proxyRequest.headers.set('X-Forwarded-Proto', 'https');
// Pass the basepath on the original URL
proxyRequest.headers.set('X-GitBook-BasePath', basePath);
// Pass the site URL
proxyRequest.headers.set('X-GitBook-Site-URL', rawSiteUrl);
return proxyRequest;
},
fetch: async (originRequest) => {
return fetch(site.request(originRequest));
},
};
return site;
}
function normalizeBasePath(basePath: string): string {
let result = withLeadingSlash(basePath);
result = withoutTrailingSlash(result);
return result;
}
function withLeadingSlash(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
function withoutTrailingSlash(path: string): string {
return path.endsWith('/') ? path.slice(0, -1) : path;
}
-23
View File
@@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -4,6 +4,7 @@ import clsx from 'clsx';
import { useRef, useState } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { Section, SectionBody, SectionHeader, SectionHeaderContent } from './StaticSection';
interface InteractiveSectionTab {
@@ -106,24 +107,25 @@ export function InteractiveSection(props: {
}}
>
{tabs.length > 1 ? (
<select
<OpenAPISelect
className={clsx(
'openapi-section-select',
'openapi-select',
`${className}-tabs-select`
)}
value={selectedTab?.key ?? ''}
onChange={(event) => {
setSelectedTab(event.target.value);
items={tabs}
selectedKey={selectedTab?.key ?? ''}
onSelectionChange={(key) => {
setSelectedTab(String(key));
state.expand();
}}
placement="bottom end"
>
{tabs.map((tab) => (
<option key={tab.key} value={tab.key}>
<OpenAPISelectItem key={tab.key} id={tab.key} value={tab}>
{tab.label}
</option>
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
) : null}
</div>
</SectionHeader>
@@ -3,15 +3,14 @@ import {
OpenAPIMediaTypeExamplesBody,
OpenAPIMediaTypeExamplesSelector,
} from './OpenAPICodeSampleInteractive';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { OpenAPICodeSampleBody } from './OpenAPICodeSampleSelector';
import { ScalarApiButton } from './ScalarApiButton';
import { StaticSection } from './StaticSection';
import { type CodeSampleGenerator, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import type { OpenAPIContext, OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
import { checkIsReference, createStateKey } from './utils';
import { checkIsReference } from './utils';
const CUSTOM_CODE_SAMPLES_KEYS = ['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const;
@@ -44,13 +43,7 @@ export function OpenAPICodeSample(props: {
return null;
}
return (
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
);
return <OpenAPICodeSampleBody data={data} items={samples} />;
}
/**
@@ -3,7 +3,8 @@ import clsx from 'clsx';
import { useCallback } from 'react';
import { useStore } from 'zustand';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
type MediaTypeState = {
mediaType: string;
@@ -15,27 +16,27 @@ function useMediaTypeState(
defaultKey: string
): MediaTypeState {
const { method, path } = data;
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.tabKey !== 'string') {
const store = useStore(getOrCreateStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.key !== 'string') {
throw new Error('Media type key is not a string');
}
return {
mediaType: store.tabKey,
setMediaType: useCallback((index: string) => store.setTabKey(index), [store.setTabKey]),
mediaType: store.key,
setMediaType: useCallback((index: string) => store.setKey(index), [store.setKey]),
};
}
function useMediaTypeSampleIndexState(data: { method: string; path: string }, mediaType: string) {
const { method, path } = data;
const store = useStore(
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
getOrCreateStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
);
if (typeof store.tabKey !== 'number') {
if (typeof store.key !== 'number') {
throw new Error('Example key is not a number');
}
return {
index: store.tabKey,
setIndex: useCallback((index: number) => store.setTabKey(index), [store.setTabKey]),
index: store.key,
setIndex: useCallback((index: number) => store.setKey(index), [store.setKey]),
};
}
@@ -69,18 +70,28 @@ function MediaTypeSelector(props: {
return null;
}
const items = renderers.map((renderer) => ({
key: renderer.mediaType,
label: renderer.mediaType,
}));
return (
<select
<OpenAPISelect
className={clsx('openapi-select')}
value={state.mediaType}
onChange={(e) => state.setMediaType(e.target.value)}
selectedKey={state.mediaType}
items={renderers.map((renderer) => ({
key: renderer.mediaType,
label: renderer.mediaType,
}))}
onSelectionChange={(e) => state.setMediaType(String(e))}
placement="bottom start"
>
{renderers.map((renderer) => (
<option key={renderer.mediaType} value={renderer.mediaType}>
{renderer.mediaType}
</option>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
);
}
@@ -95,18 +106,24 @@ function ExamplesSelector(props: {
return null;
}
const items = renderer.examples.map((example, index) => ({
key: index,
label: example.example.summary || `Example ${index + 1}`,
}));
return (
<select
className={clsx('openapi-select')}
value={String(state.index)}
onChange={(e) => state.setIndex(Number(e.target.value))}
<OpenAPISelect
items={items}
selectedKey={state.index}
onSelectionChange={(e) => state.setIndex(Number(e))}
placement="bottom start"
>
{renderer.examples.map((example, index) => (
<option key={index} value={index}>
{example.example.summary || `Example ${index + 1}`}
</option>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
</OpenAPISelectItem>
))}
</select>
</OpenAPISelect>
);
}
@@ -0,0 +1,87 @@
'use client';
import { useCallback } from 'react';
import type { Key } from 'react-aria';
import { useStore } from 'zustand';
import { OpenAPIPath } from './OpenAPIPath';
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
import { StaticSection } from './StaticSection';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
import type { OpenAPIOperationData } from './types';
function useCodeSampleState(initialKey: Key = 'default') {
const store = useStore(getOrCreateStoreByKey('codesample', initialKey));
return {
key: store.key,
setKey: useCallback((key: Key) => store.setKey(key), [store.setKey]),
};
}
type CodeSampleItem = OpenAPISelectItem & {
body: React.ReactNode;
footer?: React.ReactNode;
};
export function OpenAPICodeSampleHeader(props: {
items: CodeSampleItem[];
data: OpenAPIOperationData;
}) {
const { data, items } = props;
const state = useCodeSampleState(items[0]?.key ?? '');
const selected = items.find((item) => item.key === state.key) || items[0];
return (
<>
<OpenAPIPath canCopy={false} withServer={false} data={data} />
{items.length > 1 ? (
<OpenAPISelect
selectedKey={selected?.key}
onSelectionChange={(key) => {
state.setKey(key);
}}
items={items}
placement="bottom end"
>
{items.map((item) => (
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
{item.label}
</OpenAPISelectItem>
))}
</OpenAPISelect>
) : items[0] ? (
<span className="openapi-codesample-label">{items[0].label}</span>
) : null}
</>
);
}
export function OpenAPICodeSampleBody(props: {
items: CodeSampleItem[];
data: OpenAPIOperationData;
}) {
const { items, data } = props;
if (!items[0]) {
throw new Error('No items provided');
}
const state = useCodeSampleState(items[0]?.key);
const selected = items.find((item) => item.key === state.key) || items[0];
if (!selected) {
return null;
}
return (
<StaticSection
header={<OpenAPICodeSampleHeader data={data} items={items} />}
className="openapi-codesample"
>
<div id={selected.key as string} className="openapi-codesample-panel">
{selected.body ? selected.body : null}
{selected.footer ? selected.footer : null}
</div>
</StaticSection>
);
}
@@ -47,7 +47,7 @@ export function OpenAPIOperation(props: {
title: operation.summary,
})
: null}
<OpenAPIPath data={data} context={context} />
<OpenAPIPath data={data} />
</div>
<div className="openapi-columns">
<div className="openapi-column-spec">
+23 -6
View File
@@ -1,5 +1,5 @@
import { OpenAPICopyButton } from './OpenAPICopyButton';
import type { OpenAPIContext, OpenAPIOperationData } from './types';
import type { OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
/**
@@ -7,25 +7,42 @@ import { getDefaultServerURL } from './util/server';
*/
export function OpenAPIPath(props: {
data: OpenAPIOperationData;
context: OpenAPIContext;
/** Whether to show the server URL.
* @default true
*/
withServer?: boolean;
/**
* Whether the path is copyable.
* @default true
*/
canCopy?: boolean;
}) {
const { data } = props;
const { data, withServer = true, canCopy = true } = props;
const { method, path, operation } = data;
const server = getDefaultServerURL(data.servers);
const formattedPath = formatPath(path);
const element = (() => {
return (
<>
{withServer ? <span className="openapi-path-server">{server}</span> : null}
{formattedPath}
</>
);
})();
return (
<div className="openapi-path">
<div className={`openapi-method openapi-method-${method}`}>{method}</div>
<OpenAPICopyButton
value={server + path}
value={`${withServer ? server : ''}${path}`}
className="openapi-path-title"
data-deprecated={operation.deprecated}
isDisabled={!canCopy}
>
<span className="openapi-path-server">{server}</span>
{formattedPath}
{element}
</OpenAPICopyButton>
</div>
);
@@ -1,8 +1,11 @@
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
import { OpenAPISchemaName } from './OpenAPISchemaName';
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import type {
OpenAPIClientContext,
OpenAPIOperationData,
OpenAPISecurityWithRequired,
} from './types';
import { resolveDescription } from './utils';
/**
@@ -50,26 +53,36 @@ export function OpenAPISecurities(props: {
);
}
function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject) {
function getLabelForType(security: OpenAPISecurityWithRequired) {
switch (security.type) {
case 'apiKey':
return (
<OpenAPISchemaName
propertyName={security.name ?? 'apiKey'}
type="string"
required
required={security.required}
/>
);
case 'http':
if (security.scheme === 'basic') {
return <OpenAPISchemaName propertyName="Authorization" type="string" required />;
return (
<OpenAPISchemaName
propertyName="Authorization"
type="string"
required={security.required}
/>
);
}
if (security.scheme === 'bearer') {
const description = resolveDescription(security);
return (
<>
<OpenAPISchemaName propertyName="Authorization" type="string" required />
<OpenAPISchemaName
propertyName="Authorization"
type="string"
required={security.required}
/>
{/** Show a default description if none is provided */}
{!description ? (
<Markdown
@@ -81,11 +94,11 @@ function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject) {
);
}
return <OpenAPISchemaName propertyName="HTTP" required />;
return <OpenAPISchemaName propertyName="HTTP" required={security.required} />;
case 'oauth2':
return <OpenAPISchemaName propertyName="OAuth2" required />;
return <OpenAPISchemaName propertyName="OAuth2" required={security.required} />;
case 'openIdConnect':
return <OpenAPISchemaName propertyName="OpenID Connect" required />;
return <OpenAPISchemaName propertyName="OpenID Connect" required={security.required} />;
default:
// @ts-ignore
return security.type;
@@ -0,0 +1,70 @@
'use client';
import clsx from 'clsx';
import {
Button,
type Key,
ListBox,
ListBoxItem,
type ListBoxItemProps,
Popover,
type PopoverProps,
Select,
type SelectProps,
SelectValue,
} from 'react-aria-components';
export type OpenAPISelectItem = {
key: Key;
label: string;
};
interface OpenAPISelectProps<T extends OpenAPISelectItem> extends Omit<SelectProps<T>, 'children'> {
items: T[];
children: React.ReactNode | ((item: T) => React.ReactNode);
selectedKey?: Key;
onChange?: (key: string | number) => void;
placement?: PopoverProps['placement'];
}
export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectProps<T>) {
const { items, children, className, placement } = props;
return (
<Select {...props} className={clsx('openapi-select', className)}>
<Button>
<SelectValue />
<span aria-hidden="true">
<svg
className="gb-icon"
style={{
maskImage:
"url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/chevron-down.svg?v=2&token=a463935e93')",
maskRepeat: 'no-repeat',
maskPosition: 'center center',
}}
/>
</span>
</Button>
<Popover placement={placement} className="openapi-select-popover">
<ListBox className="openapi-select-listbox" items={items}>
{children}
</ListBox>
</Popover>
</Select>
);
}
export function OpenAPISelectItem(props: ListBoxItemProps) {
return (
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx('openapi-select-item', {
'openapi-select-item-focused': isFocused,
'openapi-select-item-selected': isSelected,
})
}
/>
);
}
+7 -7
View File
@@ -3,7 +3,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
import { useEventCallback } from 'usehooks-ts';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
export type TabItem = {
key: Key;
@@ -36,8 +36,8 @@ export function OpenAPITabs(
const { children, items, stateKey } = props;
const [tabKey, setTabKey] = useState<Key | null>(() => {
if (stateKey && typeof window !== 'undefined') {
const store = getOrCreateTabStoreByKey(stateKey);
const tabKey = store.getState().tabKey;
const store = getOrCreateStoreByKey(stateKey);
const tabKey = store.getState().key;
if (tabKey) {
return tabKey;
}
@@ -60,10 +60,10 @@ export function OpenAPITabs(
if (!stateKey) {
return undefined;
}
const store = getOrCreateTabStoreByKey(stateKey);
const store = getOrCreateStoreByKey(stateKey);
return store.subscribe((state) => {
cancelDeferRef.current?.();
cancelDeferRef.current = defer(() => selectTab(state.tabKey));
cancelDeferRef.current = defer(() => selectTab(state.key));
});
}, [stateKey, selectTab]);
useEffect(() => {
@@ -77,8 +77,8 @@ export function OpenAPITabs(
onSelectionChange={(tabKey) => {
selectTab(tabKey);
if (stateKey) {
const store = getOrCreateTabStoreByKey(stateKey);
store.setState({ tabKey });
const store = getOrCreateStoreByKey(stateKey);
store.setState({ key: tabKey });
}
}}
selectedKey={tabKey}
@@ -0,0 +1,35 @@
'use client';
import { createStore } from 'zustand';
type Key = string | number;
type State = {
key: Key | null;
};
type Actions = { setKey: (key: Key | null) => void };
type Store = State & Actions;
const createStateStore = (initial?: Key) => {
return createStore<Store>()((set) => ({
key: initial ?? null,
setKey: (key) => {
set(() => ({ key }));
},
}));
};
const defaultStores = new Map<string, ReturnType<typeof createStateStore>>();
const createStateStoreFactory = (stores: typeof defaultStores) => {
return (storeKey: string, initialKey?: Key) => {
if (!stores.has(storeKey)) {
stores.set(storeKey, createStateStore(initialKey));
}
return stores.get(storeKey)!;
};
};
export const getOrCreateStoreByKey = createStateStoreFactory(defaultStores);
@@ -40,16 +40,27 @@ export async function resolveOpenAPIOperation(
}
const servers = 'servers' in schema ? (schema.servers ?? []) : [];
const security = flattenSecurities(operation.security ?? schema.security ?? []);
const security: OpenAPIV3_1.SecurityRequirementObject[] =
operation.security ?? schema.security ?? [];
// If security includes an empty object, it means that the security is optional
const isOptionalSecurity = security.some((entry) => Object.keys(entry).length === 0);
const flatSecurities = flattenSecurities(security);
// Resolve securities
const securities: OpenAPIOperationData['securities'] = [];
for (const entry of security) {
for (const entry of flatSecurities) {
const securityKey = Object.keys(entry)[0];
if (securityKey) {
const securityScheme = schema.components?.securitySchemes?.[securityKey];
if (securityScheme && !checkIsReference(securityScheme)) {
securities.push([securityKey, securityScheme]);
securities.push([
securityKey,
{
...securityScheme,
required: !isOptionalSecurity,
},
]);
}
}
}
+3 -1
View File
@@ -57,6 +57,8 @@ export interface OpenAPIContext extends OpenAPIClientContext {
specUrl: string;
}
export type OpenAPISecurityWithRequired = OpenAPIV3.SecuritySchemeObject & { required?: boolean };
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
path: string;
method: string;
@@ -68,5 +70,5 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
operation: OpenAPIV3.OperationObject<OpenAPICustomOperationProperties>;
/** Securities that should be used for this operation */
securities: [string, OpenAPIV3.SecuritySchemeObject][];
securities: [string, OpenAPISecurityWithRequired][];
}
@@ -1,35 +0,0 @@
'use client';
import { createStore } from 'zustand';
type Key = string | number;
type TabState = {
tabKey: Key | null;
};
type TabActions = { setTabKey: (tab: Key | null) => void };
type TabStore = TabState & TabActions;
const createTabStore = (initialTab?: Key) => {
return createStore<TabStore>()((set) => ({
tabKey: initialTab ?? null,
setTabKey: (tabKey) => {
set(() => ({ tabKey }));
},
}));
};
const defaultTabStores = new Map<string, ReturnType<typeof createTabStore>>();
const createTabStoreFactory = (stores: typeof defaultTabStores) => {
return (storeKey: string, initialKey?: Key) => {
if (!stores.has(storeKey)) {
stores.set(storeKey, createTabStore(initialKey));
}
return stores.get(storeKey)!;
};
};
export const getOrCreateTabStoreByKey = createTabStoreFactory(defaultTabStores);