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