mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 22:09:04 +00:00
Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 938125e772 | |||
| 1c8d9febb3 | |||
| 223edae17d | |||
| 12a455dab5 | |||
| d00dc8ca72 | |||
| 2c85a302fc | |||
| 3460b78b45 | |||
| 95a1f652f9 | |||
| cc37e2ae15 | |||
| 08382ce62f | |||
| 80cb52a237 | |||
| c6637b09e2 | |||
| 778624af00 | |||
| e15757d01f | |||
| aed79fd6c3 | |||
| 0c973a3292 | |||
| 56c923c0ed | |||
| 7d7806df30 | |||
| cb5598dc19 | |||
| 47f01edcb8 | |||
| e6ddc0f07f | |||
| 373f18f451 | |||
| 3f292065cd | |||
| 5e975ab95b | |||
| a3ec264764 | |||
| 5d504ffa4c | |||
| 04999662db | |||
| f7a34706c7 | |||
| f328a41982 | |||
| 20ebecb114 | |||
| 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 | |||
| c22490bd80 | |||
| 860fd6d02e | |||
| c43c524804 | |||
| 66bb938d85 | |||
| 0e6d928496 | |||
| f07982d9f8 | |||
| b92ecfad58 | |||
| 416bde70bc | |||
| 580f7ade15 | |||
| cdffd7c47b | |||
| 5a959cf041 | |||
| 31466546a2 | |||
| 90ead98eaa | |||
| 65e9cb9d5c | |||
| e59076a074 | |||
| 653920dec9 | |||
| 23cedd2d8b | |||
| 70c4182fd8 | |||
| b62b101065 | |||
| b9c929beeb | |||
| 2b6c593289 | |||
| c0b339e406 | |||
| da485f5144 | |||
| 139a8050a5 | |||
| da7b369ffd | |||
| 432da64919 | |||
| 7d0b422200 | |||
| fb90eb0acc | |||
| b647538e41 | |||
| e50797a5c1 | |||
| 5b1e01c786 | |||
| 813b2af06b | |||
| 6f71da88d8 | |||
| fa91eb7eaf | |||
| c756761591 | |||
| 434af90366 | |||
| 0f41e194cb | |||
| bd353489e9 | |||
| 77fd393f15 | |||
| 8701b5e078 | |||
| 61db166f92 | |||
| 0d9b9cea0f | |||
| 7bb37c7f0a | |||
| 5b2bf828e3 | |||
| c85d3f9752 | |||
| 4d7088bbf0 | |||
| dd6ab6044c | |||
| d236bf029c | |||
| d70d566abe | |||
| a25fdedea9 | |||
| 373183a2cd | |||
| e9fa50dfb5 | |||
| 57ca4e0e88 | |||
| bc90adb73a | |||
| 1505ddbd5a | |||
| cd99ed57fb | |||
| 27de1cd01d | |||
| ae78fc5394 | |||
| 40e8e69c6d | |||
| e84a46a558 | |||
| 18a72d4466 | |||
| d7aa5fa003 |
@@ -63,9 +63,9 @@ To start your local version of GitBook, run the command `bun dev`.
|
|||||||
|
|
||||||
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
||||||
|
|
||||||
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/gitbook.com/docs` after running the development server.
|
||||||
|
|
||||||
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
||||||
|
|
||||||
### Commit your update
|
### Commit your update
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ inputs:
|
|||||||
deploy:
|
deploy:
|
||||||
description: 'Deploy as main version for all traffic instead of uploading versions'
|
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||||
required: true
|
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:
|
outputs:
|
||||||
deployment-url:
|
deployment-url:
|
||||||
description: "Deployment URL"
|
description: "Deployment URL"
|
||||||
@@ -49,10 +55,13 @@ runs:
|
|||||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
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_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_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
- name: Build worker
|
- name: Build worker
|
||||||
run: bun run turbo build:v2:cloudflare
|
run: bun run turbo build:v2:cloudflare
|
||||||
|
env:
|
||||||
|
GITBOOK_RUNTIME: cloudflare
|
||||||
shell: bash
|
shell: bash
|
||||||
- id: deploy
|
- id: deploy
|
||||||
name: Deploy to Cloudflare
|
name: Deploy to Cloudflare
|
||||||
@@ -61,9 +70,9 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '3.112.0'
|
wranglerVersion: '4.10.0'
|
||||||
environment: ${{ inputs.environment }}
|
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
|
- name: Outputs
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ runs:
|
|||||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
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_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_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
- name: Build Project Artifacts
|
- name: Build Project Artifacts
|
||||||
@@ -62,6 +63,7 @@ runs:
|
|||||||
env:
|
env:
|
||||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||||
|
GITBOOK_RUNTIME: vercel
|
||||||
- name: Deploy Project Artifacts to Vercel
|
- name: Deploy Project Artifacts to Vercel
|
||||||
id: deploy
|
id: deploy
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ jobs:
|
|||||||
run: bun run turbo gitbook#build:cloudflare
|
run: bun run turbo gitbook#build:cloudflare
|
||||||
env:
|
env:
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
|
GITBOOK_RUNTIME: cloudflare
|
||||||
- id: deploy
|
- id: deploy
|
||||||
name: Deploy to Cloudflare
|
name: Deploy to Cloudflare
|
||||||
uses: cloudflare/wrangler-action@v3.14.0
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
@@ -81,7 +82,7 @@ jobs:
|
|||||||
name: 2c-preview
|
name: 2c-preview
|
||||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
outputs:
|
outputs:
|
||||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -95,9 +96,19 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
opItem: op://gitbook-open/2c-preview
|
opItem: op://gitbook-open/2c-preview
|
||||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
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
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
|
||||||
comment-deployments:
|
comment-deployments:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Comment Deployments (preview)
|
name: Comment Deployments (preview)
|
||||||
@@ -123,14 +134,14 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
Summary of the deployments:
|
Summary of the deployments:
|
||||||
|
|
||||||
### Version 1 (production)
|
### Version 1
|
||||||
|
|
||||||
| Version | URL | Status |
|
| 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' && '✅' || '❌' }} |
|
| 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' && '✅' || '❌' }} |
|
| 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 |
|
| Version | URL | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -139,10 +150,10 @@ jobs:
|
|||||||
|
|
||||||
### Test content
|
### Test content
|
||||||
|
|
||||||
| Site | v1 | v2 |
|
| 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) |
|
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
|
||||||
| 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) |
|
| 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
|
edit-mode: replace
|
||||||
visual-testing-v1:
|
visual-testing-v1:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -184,11 +195,32 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'v2-vercel'
|
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
|
||||||
|
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:
|
visual-testing-customers-v1:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v1
|
name: Visual Testing Customers v1
|
||||||
needs: deploy-v1-cloudflare
|
needs: deploy-v1-cloudflare
|
||||||
timeout-minutes: 6
|
timeout-minutes: 8
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -208,7 +240,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2
|
name: Visual Testing Customers v2
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
timeout-minutes: 6
|
timeout-minutes: 8
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -225,6 +257,27 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'customers-v2'
|
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
|
||||||
|
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:
|
pagespeed-testing-v1:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: PageSpeed Testing v1
|
name: PageSpeed Testing v1
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Deploy staging
|
- name: Deploy
|
||||||
id: deploy
|
id: deploy
|
||||||
uses: ./.github/composite/deploy-vercel
|
uses: ./.github/composite/deploy-vercel
|
||||||
with:
|
with:
|
||||||
@@ -48,6 +48,8 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
opItem: op://gitbook-open/2c-production
|
opItem: op://gitbook-open/2c-production
|
||||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: main
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Deploy staging
|
- name: Deploy
|
||||||
id: deploy
|
id: deploy
|
||||||
uses: ./.github/composite/deploy-vercel
|
uses: ./.github/composite/deploy-vercel
|
||||||
with:
|
with:
|
||||||
@@ -48,6 +48,8 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
opItem: op://gitbook-open/2c-staging
|
opItem: op://gitbook-open/2c-staging
|
||||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: main
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
Vendored
+1
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports.biome": "explicit",
|
"source.organizeImports.biome": "explicit",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<h1 align="center">GitBook</h1>
|
<h1 align="center">GitBook</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://docs.gitbook.com/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
|
<a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -59,15 +59,15 @@ bun install
|
|||||||
4. Start your local development server.
|
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/`.
|
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
|
|
||||||
- http://localhost:3000/docs.gitbook.com
|
- http://localhost:3000/url/gitbook.com/docs
|
||||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
- 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.
|
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/**/*",
|
"**/.wrangler/**/*",
|
||||||
"packages/openapi-parser/src/fixtures/**/*",
|
"packages/openapi-parser/src/fixtures/**/*",
|
||||||
"packages/emoji-codepoints/index.ts",
|
"packages/emoji-codepoints/index.ts",
|
||||||
"packages/icons/src/data/*.json"
|
"packages/icons/src/data/*.json",
|
||||||
|
"packages/cache-do/worker-configuration.d.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
+5
-5
@@ -4,15 +4,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@changesets/cli": "^2.27.12",
|
"@changesets/cli": "^2.27.12",
|
||||||
"turbo": "^2.4.4",
|
"turbo": "^2.5.0",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.5",
|
"packageManager": "bun@1.2.11",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"react": "18.3.1",
|
"@gitbook/api": "^0.115.0",
|
||||||
"react-dom": "18.3.1",
|
"react": "^19.0.0",
|
||||||
"@gitbook/api": "0.102.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"wrangler": "^3.112.0"
|
"wrangler": "^4.10.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "wrangler types --experimental-include-runtime",
|
"generate": "wrangler types",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "tsc -w",
|
"dev": "tsc -w",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
"types": ["./worker-configuration.d.ts"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"generate": {
|
||||||
|
"outputs": ["worker-configuration.d.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# @gitbook/cache-tags
|
# @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
|
## 0.2.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.2.0",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "^0.115.0",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function getCacheTag(
|
|||||||
| {
|
| {
|
||||||
tag: 'computed-document';
|
tag: 'computed-document';
|
||||||
space: string;
|
space: string;
|
||||||
integration: string;
|
sourceType: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* All data related to the URL of a content
|
* All data related to the URL of a content
|
||||||
@@ -84,6 +84,14 @@ export function getCacheTag(
|
|||||||
organization: string;
|
organization: string;
|
||||||
openAPISpec: string;
|
openAPISpec: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* All data related to a translation
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
tag: 'translation';
|
||||||
|
organization: string;
|
||||||
|
translationSettings: string;
|
||||||
|
}
|
||||||
): string {
|
): string {
|
||||||
switch (spec.tag) {
|
switch (spec.tag) {
|
||||||
case 'user':
|
case 'user':
|
||||||
@@ -99,13 +107,15 @@ export function getCacheTag(
|
|||||||
case 'document':
|
case 'document':
|
||||||
return `space:${spec.space}:document:${spec.document}`;
|
return `space:${spec.space}:document:${spec.document}`;
|
||||||
case 'computed-document':
|
case 'computed-document':
|
||||||
return `space:${spec.space}:computed-document:${spec.integration}`;
|
return `space:${spec.space}:computed-document:${spec.sourceType}`;
|
||||||
case 'site':
|
case 'site':
|
||||||
return `site:${spec.site}`;
|
return `site:${spec.site}`;
|
||||||
case 'integration':
|
case 'integration':
|
||||||
return `integration:${spec.integration}`;
|
return `integration:${spec.integration}`;
|
||||||
case 'openapi':
|
case 'openapi':
|
||||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||||
|
case 'translation':
|
||||||
|
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
||||||
default:
|
default:
|
||||||
assertNever(spec);
|
assertNever(spec);
|
||||||
}
|
}
|
||||||
@@ -157,6 +167,15 @@ export function getComputedContentSourceCacheTags(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'translation-language':
|
||||||
|
tags.push(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'translation',
|
||||||
|
organization: inContext.organizationId,
|
||||||
|
translationSettings: dependency.ref.translationSettings,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Do not throw for unknown dependency types
|
// Do not throw for unknown dependency types
|
||||||
// as it might mean we are lacking behind the API version
|
// as it might mean we are lacking behind the API version
|
||||||
@@ -169,18 +188,22 @@ export function getComputedContentSourceCacheTags(
|
|||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'computed-document',
|
tag: 'computed-document',
|
||||||
space: inContext.spaceId,
|
space: inContext.spaceId,
|
||||||
integration: source.integration,
|
sourceType: source.type,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We invalidate the computed content when a new version of the integration is deployed.
|
// We invalidate the computed content when a new version of the integration is deployed.
|
||||||
|
|
||||||
|
if (source.type.startsWith('integration:')) {
|
||||||
|
const integration = source.type.split(':')[1];
|
||||||
tags.push(
|
tags.push(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'integration',
|
tag: 'integration',
|
||||||
integration: source.integration,
|
integration,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# @gitbook/colors
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- c3f6b8c: Update chroma ratio per step
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- fb90eb0: Reduce chroma of first color scale step
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,13 +214,31 @@ export function colorScale(
|
|||||||
const targetL =
|
const targetL =
|
||||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||||
|
|
||||||
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
|
if (
|
||||||
|
index === 8 &&
|
||||||
|
!mix &&
|
||||||
|
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
|
||||||
|
) {
|
||||||
// Original colour is close enough to target, so let's use the original colour as step 9.
|
// Original colour is close enough to target, so let's use the original colour as step 9.
|
||||||
result.push(hex);
|
result.push(hex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
|
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 = {
|
const shade = {
|
||||||
L: targetL, // Blend lightness
|
L: targetL, // Blend lightness
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
# cloudflare
|
# cloudflare
|
||||||
.open-next
|
.open-next
|
||||||
|
.wrangler
|
||||||
|
|
||||||
# Symbolic links
|
# Symbolic links
|
||||||
public
|
public
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# gitbook-v2
|
# gitbook-v2
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 3119066: Add support for reusable content across spaces.
|
||||||
|
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 1c8d9fe: keep data cache in OpenNext between deployment
|
||||||
|
- 778624a: Only resize images with supported extensions.
|
||||||
|
- e6ddc0f: Fix URL in sitemap
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- e15757d: Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
|
||||||
|
- 634e0b4: Improve error messages around undefined site sections.
|
||||||
|
- 97b7c79: Increase logging around caching behaviour causing page crashes.
|
||||||
|
- 3f29206: Update the regex for validating site redirect
|
||||||
|
- dd043df: Revert investigation work around URL caches.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
|
||||||
|
|
||||||
## 0.2.2
|
## 0.2.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||||
authInterrupts: true,
|
authInterrupts: true,
|
||||||
|
|
||||||
// This is needed to use 'use cache'
|
|
||||||
useCache: true,
|
useCache: true,
|
||||||
|
|
||||||
// Content is fully static, we can cache it in the session memory cache for a long time
|
// 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_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
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_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||||
|
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||||
|
|
||||||
// Next.js envs
|
// Next.js envs
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
||||||
import d1TagCache from '@opennextjs/cloudflare/d1-tag-cache';
|
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||||
import kvIncrementalCache from '@opennextjs/cloudflare/kv-cache';
|
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||||
import memoryQueue from '@opennextjs/cloudflare/memory-queue';
|
import {
|
||||||
|
softTagFilter,
|
||||||
|
withFilter,
|
||||||
|
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
export default defineCloudflareConfig({
|
||||||
incrementalCache: kvIncrementalCache,
|
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||||
queue: memoryQueue,
|
tagCache: withFilter({
|
||||||
tagCache: d1TagCache,
|
tagCache: doShardedTagCache({
|
||||||
|
baseShardSize: 12,
|
||||||
|
regionalCache: true,
|
||||||
|
shardReplication: {
|
||||||
|
numberOfSoftReplicas: 2,
|
||||||
|
numberOfHardReplicas: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// We don't use `revalidatePath`, so we filter out soft tags
|
||||||
|
filterFn: softTagFilter,
|
||||||
|
}),
|
||||||
|
queue: doQueue,
|
||||||
|
|
||||||
|
// Performance improvements as we don't use PPR
|
||||||
|
enableCacheInterception: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { trace } from '@/lib/tracing';
|
||||||
|
import type {
|
||||||
|
CacheEntryType,
|
||||||
|
CacheValue,
|
||||||
|
IncrementalCache,
|
||||||
|
WithLastModified,
|
||||||
|
} from '@opennextjs/aws/types/overrides.js';
|
||||||
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
|
|
||||||
|
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
||||||
|
export const DEFAULT_PREFIX = 'incremental-cache';
|
||||||
|
|
||||||
|
export type KeyOptions = {
|
||||||
|
cacheType?: CacheEntryType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
|
||||||
|
* the cache operations. It also integrates both R2 and Cache API in a single class.
|
||||||
|
* Having our own, will allow us to customize it in the future if needed.
|
||||||
|
*/
|
||||||
|
class GitbookIncrementalCache implements IncrementalCache {
|
||||||
|
name = 'GitbookIncrementalCache';
|
||||||
|
|
||||||
|
protected localCache: Cache | undefined;
|
||||||
|
|
||||||
|
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||||
|
key: string,
|
||||||
|
cacheType?: CacheType
|
||||||
|
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||||
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'openNextIncrementalCacheGet',
|
||||||
|
name: cacheKey,
|
||||||
|
},
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
try {
|
||||||
|
// Check local cache first if available
|
||||||
|
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||||
|
if (localCacheEntry) {
|
||||||
|
span.setAttribute('cacheHit', 'local');
|
||||||
|
return localCacheEntry.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const r2Object = await r2.get(cacheKey);
|
||||||
|
if (!r2Object) return null;
|
||||||
|
|
||||||
|
span.setAttribute('cacheHit', 'r2');
|
||||||
|
return {
|
||||||
|
value: await r2Object.json(),
|
||||||
|
lastModified: r2Object.uploaded.getTime(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get from cache', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<CacheType extends CacheEntryType = 'cache'>(
|
||||||
|
key: string,
|
||||||
|
value: CacheValue<CacheType>,
|
||||||
|
cacheType?: CacheType
|
||||||
|
): Promise<void> {
|
||||||
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'openNextIncrementalCacheSet',
|
||||||
|
name: cacheKey,
|
||||||
|
},
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await r2.put(cacheKey, JSON.stringify(value));
|
||||||
|
|
||||||
|
//TODO: Check if there is any places where we don't have tags
|
||||||
|
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||||
|
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||||
|
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||||
|
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// We consider R2 as the source of truth, so we update the local cache
|
||||||
|
// only after a successful R2 write
|
||||||
|
await localCache.put(
|
||||||
|
this.getCacheUrlKey(cacheKey),
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
value,
|
||||||
|
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||||
|
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||||
|
lastModified: Date.now(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||||
|
// In theory we should always get the `revalidate` value
|
||||||
|
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||||
|
'cache-tag': tags.join(','),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set to cache', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
const cacheKey = this.getR2Key(key);
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'openNextIncrementalCacheDelete',
|
||||||
|
name: cacheKey,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await r2.delete(cacheKey);
|
||||||
|
|
||||||
|
// Here again R2 is the source of truth, so we delete from local cache first
|
||||||
|
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete from cache', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCacheInstance(): Promise<Cache> {
|
||||||
|
if (this.localCache) return this.localCache;
|
||||||
|
this.localCache = await caches.open('incremental-cache');
|
||||||
|
return this.localCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to generate keys for R2/Cache API
|
||||||
|
getR2Key(key: string, cacheType: CacheEntryType = 'cache'): string {
|
||||||
|
const hash = createHash('sha256').update(key).digest('hex');
|
||||||
|
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
|
||||||
|
/\/+/g,
|
||||||
|
'/'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheUrlKey(cacheKey: string): string {
|
||||||
|
return `http://cache.local/${cacheKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
|
||||||
|
entry: CacheValue<CacheType>
|
||||||
|
): string[] | undefined {
|
||||||
|
if ('tags' in entry && entry.tags) {
|
||||||
|
return entry.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
|
||||||
|
const rawTags = entry.meta.headers['x-next-cache-tags'];
|
||||||
|
if (typeof rawTags === 'string') {
|
||||||
|
return rawTags.split(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('value' in entry) {
|
||||||
|
return entry.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new GitbookIncrementalCache();
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook-v2",
|
"name": "gitbook-v2",
|
||||||
"version": "0.2.2",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.2.3",
|
"@gitbook/api": "^0.115.0",
|
||||||
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
|
"@opennextjs/cloudflare": "1.0.3",
|
||||||
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
|
"assert-never": "^1.2.1",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"next": "^15.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@gitbook/api": "*",
|
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"warn-once": "^0.1.1",
|
|
||||||
"rison": "^0.1.1",
|
"rison": "^0.1.1",
|
||||||
"jwt-decode": "^4.0.0"
|
"server-only": "^0.0.1",
|
||||||
|
"warn-once": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gitbook": "*",
|
"gitbook": "*",
|
||||||
"@opennextjs/cloudflare": "^0.5.10",
|
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"postcss": "^8"
|
"postcss": "^8"
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:v2": "next build",
|
"build:v2": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build:v2:cloudflare": "opennextjs-cloudflare",
|
"build:v2:cloudflare": "opennextjs-cloudflare build",
|
||||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||||
"unit": "bun test",
|
"unit": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export default async function SiteDynamicLayout({
|
|||||||
const forcedTheme = await getThemeFromMiddleware();
|
const forcedTheme = await getThemeFromMiddleware();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
import { serveLLMsTxt } from '@/routes/llms';
|
|
||||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
|
||||||
) {
|
|
||||||
const { context } = await getDynamicSiteContext(await params);
|
|
||||||
return serveLLMsTxt(context);
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
import { serveRobotsTxt } from '@/routes/robots';
|
|
||||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
|
||||||
) {
|
|
||||||
const { context } = await getDynamicSiteContext(await params);
|
|
||||||
return serveRobotsTxt(context);
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
import { servePagesSitemap } from '@/routes/sitemap';
|
|
||||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
|
||||||
) {
|
|
||||||
const { context } = await getDynamicSiteContext(await params);
|
|
||||||
return servePagesSitemap(context);
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
|
||||||
|
|
||||||
import { serveRootSitemap } from '@/routes/sitemap';
|
|
||||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
_request: NextRequest,
|
|
||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
|
||||||
) {
|
|
||||||
const { context } = await getDynamicSiteContext(await params);
|
|
||||||
return serveRootSitemap(context);
|
|
||||||
}
|
|
||||||
+1
-11
@@ -3,10 +3,9 @@ import {
|
|||||||
generateSitePageMetadata,
|
generateSitePageMetadata,
|
||||||
generateSitePageViewport,
|
generateSitePageViewport,
|
||||||
} from '@/components/SitePage';
|
} from '@/components/SitePage';
|
||||||
import { getCacheTag } from '@gitbook/cache-tags';
|
|
||||||
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
@@ -15,19 +14,10 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
'use cache';
|
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getStaticSiteContext(params);
|
const { context } = await getStaticSiteContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
cacheTag(
|
|
||||||
getCacheTag({
|
|
||||||
tag: 'site',
|
|
||||||
site: context.site.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
generateSiteLayoutMetadata,
|
generateSiteLayoutMetadata,
|
||||||
generateSiteLayoutViewport,
|
generateSiteLayoutViewport,
|
||||||
} from '@/components/SiteLayout';
|
} from '@/components/SiteLayout';
|
||||||
import { getCacheTag } from '@gitbook/cache-tags';
|
|
||||||
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
|
||||||
|
|
||||||
interface SiteStaticLayoutProps {
|
interface SiteStaticLayoutProps {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
@@ -17,17 +15,8 @@ export default async function SiteStaticLayout({
|
|||||||
params,
|
params,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
'use cache';
|
|
||||||
|
|
||||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||||
|
|
||||||
cacheTag(
|
|
||||||
getCacheTag({
|
|
||||||
tag: 'site',
|
|
||||||
site: context.site.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout customization={context.customization}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
|
|||||||
+1
-1
@@ -10,5 +10,5 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<RouteLayoutParams> }
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
) {
|
) {
|
||||||
const { context } = await getStaticSiteContext(await params);
|
const { context } = await getStaticSiteContext(await params);
|
||||||
return serveLLMsTxt(context);
|
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { servePageMarkdown } from '@/routes/markdownPage';
|
||||||
|
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const { context } = await getStaticSiteContext(await params);
|
||||||
|
const pathname = getPagePathFromParams(await params);
|
||||||
|
return servePageMarkdown(context, pathname);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||||
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
|
import type { SiteAPIToken } from '@gitbook/api';
|
||||||
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { jwtDecode } from 'jwt-decode';
|
||||||
import { forbidden } from 'next/navigation';
|
import { forbidden } from 'next/navigation';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
@@ -98,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
|
|||||||
/**
|
/**
|
||||||
* Get the decoded site data from the params.
|
* Get the decoded site data from the params.
|
||||||
*/
|
*/
|
||||||
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
|
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||||
const decoded = decodeURIComponent(params.siteData);
|
const decoded = decodeURIComponent(params.siteData);
|
||||||
return rison.decode(decoded);
|
return rison.decode(decoded);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,43 @@ import type {
|
|||||||
SiteStructure,
|
SiteStructure,
|
||||||
Space,
|
Space,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
|
import {
|
||||||
|
type GitBookDataFetcher,
|
||||||
|
createDataFetcher,
|
||||||
|
getDataOrNull,
|
||||||
|
throwIfDataError,
|
||||||
|
} from '@v2/lib/data';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
import { assert } from 'ts-essentials';
|
import { assert } from 'ts-essentials';
|
||||||
import { GITBOOK_URL } from './env';
|
import { GITBOOK_URL } from './env';
|
||||||
import { type ImageResizer, createImageResizer } from './images';
|
import { type ImageResizer, createImageResizer } from './images';
|
||||||
import { type GitBookLinker, createLinker } from './links';
|
import { type GitBookLinker, createLinker } from './links';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data about the site URL. Provided by the middleware.
|
||||||
|
* These data are stable between pages in the same site space.
|
||||||
|
*/
|
||||||
|
export type SiteURLData = Pick<
|
||||||
|
PublishedSiteContent,
|
||||||
|
| 'organization'
|
||||||
|
| 'apiToken'
|
||||||
|
| 'site'
|
||||||
|
| 'siteSpace'
|
||||||
|
| 'space'
|
||||||
|
| 'revision'
|
||||||
|
| 'changeRequest'
|
||||||
|
| 'shareKey'
|
||||||
|
| 'siteSection'
|
||||||
|
| 'siteBasePath'
|
||||||
|
| 'basePath'
|
||||||
|
> & {
|
||||||
|
/**
|
||||||
|
* Identifier used for image resizing.
|
||||||
|
*/
|
||||||
|
imagesContextId: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic context when rendering content.
|
* Generic context when rendering content.
|
||||||
*/
|
*/
|
||||||
@@ -105,7 +136,7 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
|
|||||||
*/
|
*/
|
||||||
export function getBaseContext(input: {
|
export function getBaseContext(input: {
|
||||||
siteURL: URL | string;
|
siteURL: URL | string;
|
||||||
siteURLData: PublishedSiteContent;
|
siteURLData: SiteURLData;
|
||||||
urlMode: 'url' | 'url-host';
|
urlMode: 'url' | 'url-host';
|
||||||
}) {
|
}) {
|
||||||
const { urlMode, siteURLData } = input;
|
const { urlMode, siteURLData } = input;
|
||||||
@@ -139,7 +170,7 @@ export function getBaseContext(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageResizer = createImageResizer({
|
const imageResizer = createImageResizer({
|
||||||
host: siteURL.host,
|
imagesContextId: siteURLData.imagesContextId,
|
||||||
// To ensure image resizing work for proxied sites,
|
// To ensure image resizing work for proxied sites,
|
||||||
// we serve images from the root of the site.
|
// we serve images from the root of the site.
|
||||||
linker: linker,
|
linker: linker,
|
||||||
@@ -157,7 +188,7 @@ export function getBaseContext(input: {
|
|||||||
*/
|
*/
|
||||||
export async function fetchSiteContextByURLLookup(
|
export async function fetchSiteContextByURLLookup(
|
||||||
baseContext: GitBookBaseContext,
|
baseContext: GitBookBaseContext,
|
||||||
data: PublishedSiteContent
|
data: SiteURLData
|
||||||
): Promise<GitBookSiteContext> {
|
): Promise<GitBookSiteContext> {
|
||||||
return await fetchSiteContextByIds(baseContext, {
|
return await fetchSiteContextByIds(baseContext, {
|
||||||
organization: data.organization,
|
organization: data.organization,
|
||||||
@@ -212,19 +243,45 @@ export async function fetchSiteContextByIds(
|
|||||||
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const siteSpace = (
|
// Parse the current siteSpace and siteSpaces based on the site structure type.
|
||||||
siteStructure.type === 'siteSpaces' && siteStructure.structure
|
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
|
||||||
? siteStructure.structure
|
if (siteStructure.type === 'siteSpaces') {
|
||||||
: sections?.current.siteSpaces
|
const siteSpaces = siteStructure.structure;
|
||||||
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||||
|
|
||||||
if (!siteSpace) {
|
if (!siteSpace) {
|
||||||
throw new Error('Site space not found');
|
throw new Error(
|
||||||
|
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteSpaces =
|
return { siteSpaces, siteSpace };
|
||||||
siteStructure.type === 'siteSpaces'
|
}
|
||||||
? siteStructure.structure
|
|
||||||
: (sections?.current.siteSpaces ?? []);
|
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 = (() => {
|
const customization = (() => {
|
||||||
if (ids.siteSpace) {
|
if (ids.siteSpace) {
|
||||||
@@ -277,7 +334,7 @@ export async function fetchSpaceContextByIds(
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
ids.changeRequest
|
ids.changeRequest
|
||||||
? throwIfDataError(
|
? getDataOrNull(
|
||||||
dataFetcher.getChangeRequest({
|
dataFetcher.getChangeRequest({
|
||||||
spaceId: ids.space,
|
spaceId: ids.space,
|
||||||
changeRequestId: ids.changeRequest,
|
changeRequestId: ids.changeRequest,
|
||||||
@@ -286,17 +343,30 @@ export async function fetchSpaceContextByIds(
|
|||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
|
if (ids.changeRequest && !changeRequest) {
|
||||||
|
// When trying to render a change request with an invalid / non-existing ID,
|
||||||
|
// we should return a 404.
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const pages = await throwIfDataError(
|
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
|
||||||
|
|
||||||
|
const pages = await getDataOrNull(
|
||||||
dataFetcher.getRevisionPages({
|
dataFetcher.getRevisionPages({
|
||||||
spaceId: ids.space,
|
spaceId: ids.space,
|
||||||
revisionId,
|
revisionId,
|
||||||
// We only care about the Git metadata when the Git sync is enabled,
|
// We only care about the Git metadata when the Git sync is enabled,
|
||||||
// otherwise we can optimize performance by not fetching it
|
// otherwise we can optimize performance by not fetching it
|
||||||
metadata: !!space.gitSync,
|
metadata: !!space.gitSync,
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
// When trying to render a revision with an invalid / non-existing ID,
|
||||||
|
// we should handle gracefully the 404 and throw notFound.
|
||||||
|
ids.revision ? [404] : undefined
|
||||||
);
|
);
|
||||||
|
if (!pages) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseContext,
|
...baseContext,
|
||||||
@@ -337,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
|
|||||||
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||||
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
||||||
const section = parseCurrentSection(structure, siteSectionId);
|
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;
|
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import {
|
|||||||
type ComputedContentSource,
|
type ComputedContentSource,
|
||||||
GitBookAPI,
|
GitBookAPI,
|
||||||
type GitBookAPIServiceBinding,
|
type GitBookAPIServiceBinding,
|
||||||
|
type HttpResponse,
|
||||||
type RenderIntegrationUI,
|
type RenderIntegrationUI,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||||
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
||||||
import { wrapDataFetcherError } from './errors';
|
import { getCloudflareContext, getCloudflareRequestGlobal } from './cloudflare';
|
||||||
|
import { DataFetcherError, wrapDataFetcherError } from './errors';
|
||||||
|
import { withCacheKey, withoutConcurrentExecution } from './memoize';
|
||||||
import type { GitBookDataFetcher } from './types';
|
import type { GitBookDataFetcher } from './types';
|
||||||
|
|
||||||
interface DataFetcherInput {
|
interface DataFetcherInput {
|
||||||
@@ -18,6 +21,15 @@ interface DataFetcherInput {
|
|||||||
apiToken: string | null;
|
apiToken: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options to pass to the `fetch` call to disable the Next data-cache when wrapped in `use cache`.
|
||||||
|
*/
|
||||||
|
export const noCacheFetchOptions: Partial<RequestInit> = {
|
||||||
|
next: {
|
||||||
|
revalidate: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a data fetcher using an API token.
|
* Create a data fetcher using an API token.
|
||||||
* The data are being cached by Next.js built-in cache.
|
* The data are being cached by Next.js built-in cache.
|
||||||
@@ -94,6 +106,15 @@ export function createDataFetcher(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getRevisionPageMarkdown(params) {
|
||||||
|
return trace('getRevisionPageMarkdown', () =>
|
||||||
|
getRevisionPageMarkdown(input, {
|
||||||
|
spaceId: params.spaceId,
|
||||||
|
revisionId: params.revisionId,
|
||||||
|
pageId: params.pageId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
getReusableContent(params) {
|
getReusableContent(params) {
|
||||||
return trace('getReusableContent', () =>
|
return trace('getReusableContent', () =>
|
||||||
getReusableContent(input, {
|
getReusableContent(input, {
|
||||||
@@ -165,38 +186,46 @@ export function createDataFetcher(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
//
|
|
||||||
// API that are not tied to the token
|
|
||||||
// where the data is the same for all users
|
|
||||||
//
|
|
||||||
getUserById(userId) {
|
getUserById(userId) {
|
||||||
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
|
return trace('getUserById', () => getUserById(input, { userId }));
|
||||||
|
},
|
||||||
|
|
||||||
|
streamAIResponse(params) {
|
||||||
|
return streamAIResponse(input, params);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserById(input: DataFetcherInput, params: { userId: string }) {
|
const getUserById = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (_, input: DataFetcherInput, params: { userId: string }) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
return trace(`getUserById(${params.userId})`, async () => {
|
||||||
cacheLife('days');
|
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.users.getUserById(params.userId);
|
const res = await api.users.getUserById(params.userId, {
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
});
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('days');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function getSpace(
|
const getSpace = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: { spaceId: string; shareKey: string | undefined }
|
||||||
spaceId: string;
|
) => {
|
||||||
shareKey: string | undefined;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('days');
|
|
||||||
cacheTag(
|
cacheTag(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'space',
|
tag: 'space',
|
||||||
@@ -204,151 +233,265 @@ async function getSpace(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getSpaceById(params.spaceId, {
|
const res = await api.spaces.getSpaceById(
|
||||||
|
params.spaceId,
|
||||||
|
{
|
||||||
shareKey: params.shareKey,
|
shareKey: params.shareKey,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('days');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function getChangeRequest(
|
const getChangeRequest = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: { spaceId: string; changeRequestId: string }
|
||||||
spaceId: string;
|
) => {
|
||||||
changeRequestId: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('minutes');
|
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
|
||||||
const api = await apiClient(input);
|
|
||||||
const res = await api.spaces.getChangeRequestById(params.spaceId, params.changeRequestId);
|
|
||||||
cacheTag(
|
cacheTag(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'change-request',
|
tag: 'change-request',
|
||||||
space: params.spaceId,
|
space: params.spaceId,
|
||||||
changeRequest: res.data.id,
|
changeRequest: params.changeRequestId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return trace(
|
||||||
|
`getChangeRequest(${params.spaceId}, ${params.changeRequestId})`,
|
||||||
|
async () => {
|
||||||
|
return wrapDataFetcherError(async () => {
|
||||||
|
const api = apiClient(input);
|
||||||
|
const res = await api.spaces.getChangeRequestById(
|
||||||
|
params.spaceId,
|
||||||
|
params.changeRequestId,
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('minutes');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function getRevision(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
spaceId: string;
|
|
||||||
revisionId: string;
|
|
||||||
metadata: boolean;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRevision = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { spaceId: string; revisionId: string; metadata: boolean }
|
||||||
|
) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => {
|
||||||
cacheLife('max');
|
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
const res = await api.spaces.getRevisionById(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
{
|
||||||
metadata: params.metadata,
|
metadata: params.metadata,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
async function getRevisionPages(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
spaceId: string;
|
|
||||||
revisionId: string;
|
|
||||||
metadata: boolean;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
'use cache';
|
|
||||||
|
|
||||||
cacheLife('max');
|
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
|
||||||
const api = await apiClient(input);
|
|
||||||
const res = await api.spaces.listPagesInRevisionById(params.spaceId, params.revisionId, {
|
|
||||||
metadata: params.metadata,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRevisionPages = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { spaceId: string; revisionId: string; metadata: boolean }
|
||||||
|
) => {
|
||||||
|
'use cache';
|
||||||
|
return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => {
|
||||||
|
return wrapDataFetcherError(async () => {
|
||||||
|
const api = apiClient(input);
|
||||||
|
const res = await api.spaces.listPagesInRevisionById(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
{
|
||||||
|
metadata: params.metadata,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data.pages;
|
return res.data.pages;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function getRevisionFile(
|
const getRevisionFile = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: { spaceId: string; revisionId: string; fileId: string }
|
||||||
spaceId: string;
|
) => {
|
||||||
revisionId: string;
|
|
||||||
fileId: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
'use cache';
|
'use cache';
|
||||||
|
return trace(
|
||||||
cacheLife('max');
|
`getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`,
|
||||||
|
async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getFileInRevisionById(
|
const res = await api.spaces.getFileInRevisionById(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
params.revisionId,
|
params.revisionId,
|
||||||
params.fileId,
|
params.fileId,
|
||||||
{}
|
{},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function getRevisionPageByPath(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
spaceId: string;
|
|
||||||
revisionId: string;
|
|
||||||
path: string;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
'use cache';
|
);
|
||||||
|
|
||||||
|
const getRevisionPageMarkdown = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { spaceId: string; revisionId: string; pageId: string }
|
||||||
|
) => {
|
||||||
|
'use cache';
|
||||||
|
return trace(
|
||||||
|
`getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
|
||||||
|
async () => {
|
||||||
|
return wrapDataFetcherError(async () => {
|
||||||
|
const api = apiClient(input);
|
||||||
|
const res = await api.spaces.getPageInRevisionById(
|
||||||
|
params.spaceId,
|
||||||
|
params.revisionId,
|
||||||
|
params.pageId,
|
||||||
|
{
|
||||||
|
format: 'markdown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
cacheLife('max');
|
cacheLife('max');
|
||||||
|
|
||||||
|
if (!('markdown' in res.data)) {
|
||||||
|
throw new DataFetcherError('Page is not a document', 404);
|
||||||
|
}
|
||||||
|
return res.data.markdown;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRevisionPageByPath = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { spaceId: string; revisionId: string; path: string }
|
||||||
|
) => {
|
||||||
|
'use cache';
|
||||||
|
return trace(
|
||||||
|
`getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`,
|
||||||
|
async () => {
|
||||||
const encodedPath = encodeURIComponent(params.path);
|
const encodedPath = encodeURIComponent(params.path);
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getPageInRevisionByPath(
|
const res = await api.spaces.getPageInRevisionByPath(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
params.revisionId,
|
params.revisionId,
|
||||||
encodedPath,
|
encodedPath,
|
||||||
{}
|
{},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
|
return res.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.data;
|
const getDocument = withCacheKey(
|
||||||
});
|
withoutConcurrentExecution(
|
||||||
}
|
getCloudflareRequestGlobal,
|
||||||
|
async (_, input: DataFetcherInput, params: { spaceId: string; documentId: string }) => {
|
||||||
async function getDocument(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
spaceId: string;
|
|
||||||
documentId: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
'use cache';
|
'use cache';
|
||||||
|
return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => {
|
||||||
cacheLife('max');
|
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
|
const res = await api.spaces.getDocumentById(
|
||||||
|
params.spaceId,
|
||||||
|
params.documentId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function getComputedDocument(
|
const getComputedDocument = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -356,11 +499,8 @@ async function getComputedDocument(
|
|||||||
source: ComputedContentSource;
|
source: ComputedContentSource;
|
||||||
seed: string;
|
seed: string;
|
||||||
}
|
}
|
||||||
) {
|
) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheLife('days');
|
|
||||||
|
|
||||||
cacheTag(
|
cacheTag(
|
||||||
...getComputedContentSourceCacheTags(
|
...getComputedContentSourceCacheTags(
|
||||||
{
|
{
|
||||||
@@ -371,48 +511,70 @@ async function getComputedDocument(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return trace(
|
||||||
|
`getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`,
|
||||||
|
async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
const res = await api.spaces.getComputedDocument(
|
||||||
|
params.spaceId,
|
||||||
|
{
|
||||||
source: params.source,
|
source: params.source,
|
||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
});
|
},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function getReusableContent(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
spaceId: string;
|
|
||||||
revisionId: string;
|
|
||||||
reusableContentId: string;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReusableContent = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { spaceId: string; revisionId: string; reusableContentId: string }
|
||||||
|
) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
return trace(
|
||||||
cacheLife('max');
|
`getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`,
|
||||||
|
async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getReusableContentInRevisionById(
|
const res = await api.spaces.getReusableContentInRevisionById(
|
||||||
params.spaceId,
|
params.spaceId,
|
||||||
params.revisionId,
|
params.revisionId,
|
||||||
params.reusableContentId
|
params.reusableContentId,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function getLatestOpenAPISpecVersionContent(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
organizationId: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
'use cache';
|
);
|
||||||
|
|
||||||
|
const getLatestOpenAPISpecVersionContent = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (_, input: DataFetcherInput, params: { organizationId: string; slug: string }) => {
|
||||||
|
'use cache';
|
||||||
cacheTag(
|
cacheTag(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'openapi',
|
tag: 'openapi',
|
||||||
@@ -420,29 +582,38 @@ async function getLatestOpenAPISpecVersionContent(
|
|||||||
openAPISpec: params.slug,
|
openAPISpec: params.slug,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
cacheLife('days');
|
|
||||||
|
|
||||||
|
return trace(
|
||||||
|
`getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`,
|
||||||
|
async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
|
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
|
||||||
params.organizationId,
|
params.organizationId,
|
||||||
params.slug
|
params.slug,
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('max');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function getPublishedContentSite(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
organizationId: string;
|
|
||||||
siteId: string;
|
|
||||||
siteShareKey: string | undefined;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
'use cache';
|
);
|
||||||
|
|
||||||
cacheLife('days');
|
const getPublishedContentSite = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { organizationId: string; siteId: string; siteShareKey: string | undefined }
|
||||||
|
) => {
|
||||||
|
'use cache';
|
||||||
cacheTag(
|
cacheTag(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'site',
|
tag: 'site',
|
||||||
@@ -450,16 +621,36 @@ async function getPublishedContentSite(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return trace(
|
||||||
|
`getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`,
|
||||||
|
async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.orgs.getPublishedContentSite(params.organizationId, params.siteId, {
|
const res = await api.orgs.getPublishedContentSite(
|
||||||
|
params.organizationId,
|
||||||
|
params.siteId,
|
||||||
|
{
|
||||||
shareKey: params.siteShareKey,
|
shareKey: params.siteShareKey,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('days');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function getSiteRedirectBySource(
|
const getSiteRedirectBySource = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: {
|
params: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -467,86 +658,174 @@ async function getSiteRedirectBySource(
|
|||||||
siteShareKey: string | undefined;
|
siteShareKey: string | undefined;
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
) {
|
) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
|
||||||
cacheTag(
|
cacheTag(
|
||||||
getCacheTag({
|
getCacheTag({
|
||||||
tag: 'site',
|
tag: 'site',
|
||||||
site: params.siteId,
|
site: params.siteId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
cacheLife('days');
|
|
||||||
|
|
||||||
|
return trace(
|
||||||
|
`getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`,
|
||||||
|
async () => {
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.orgs.getSiteRedirectBySource(params.organizationId, params.siteId, {
|
const res = await api.orgs.getSiteRedirectBySource(
|
||||||
|
params.organizationId,
|
||||||
|
params.siteId,
|
||||||
|
{
|
||||||
shareKey: params.siteShareKey,
|
shareKey: params.siteShareKey,
|
||||||
source: params.source,
|
source: params.source,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('days');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function getEmbedByUrl(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
url: string;
|
|
||||||
spaceId: string;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getEmbedByUrl = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (_, input: DataFetcherInput, params: { spaceId: string; url: string }) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'space',
|
||||||
|
space: params.spaceId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
cacheLife('weeks');
|
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => {
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
const res = await api.spaces.getEmbedByUrlInSpace(
|
||||||
|
params.spaceId,
|
||||||
|
{
|
||||||
|
url: params.url,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('weeks');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
async function searchSiteContent(
|
const searchSiteContent = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
input: DataFetcherInput,
|
input: DataFetcherInput,
|
||||||
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
||||||
) {
|
) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'site',
|
||||||
|
site: params.siteId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const { organizationId, siteId, query, scope } = params;
|
return trace(
|
||||||
|
`searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`,
|
||||||
cacheLife('days');
|
async () => {
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const { organizationId, siteId, query, scope } = params;
|
||||||
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
const api = apiClient(input);
|
||||||
|
const res = await api.orgs.searchSiteContent(
|
||||||
|
organizationId,
|
||||||
|
siteId,
|
||||||
|
{
|
||||||
query,
|
query,
|
||||||
...scope,
|
...scope,
|
||||||
});
|
},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('hours');
|
||||||
return res.data.items;
|
return res.data.items;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
async function renderIntegrationUi(
|
|
||||||
input: DataFetcherInput,
|
|
||||||
params: {
|
|
||||||
integrationName: string;
|
|
||||||
request: RenderIntegrationUI;
|
|
||||||
}
|
}
|
||||||
) {
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderIntegrationUi = withCacheKey(
|
||||||
|
withoutConcurrentExecution(
|
||||||
|
getCloudflareRequestGlobal,
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: { integrationName: string; request: RenderIntegrationUI }
|
||||||
|
) => {
|
||||||
'use cache';
|
'use cache';
|
||||||
|
cacheTag(
|
||||||
|
getCacheTag({
|
||||||
|
tag: 'integration',
|
||||||
|
integration: params.integrationName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
|
return trace(`renderIntegrationUi(${params.integrationName})`, async () => {
|
||||||
cacheLife('days');
|
|
||||||
|
|
||||||
return wrapDataFetcherError(async () => {
|
return wrapDataFetcherError(async () => {
|
||||||
const api = await apiClient(input);
|
const api = apiClient(input);
|
||||||
const res = await api.integrations.renderIntegrationUiWithPost(
|
const res = await api.integrations.renderIntegrationUiWithPost(
|
||||||
params.integrationName,
|
params.integrationName,
|
||||||
params.request
|
params.request,
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
cacheTag(...getCacheTagsFromResponse(res));
|
||||||
|
cacheLife('days');
|
||||||
return res.data;
|
return res.data;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function* streamAIResponse(
|
||||||
|
input: DataFetcherInput,
|
||||||
|
params: Parameters<GitBookDataFetcher['streamAIResponse']>[0]
|
||||||
|
) {
|
||||||
|
const api = apiClient(input);
|
||||||
|
const res = await api.orgs.streamAiResponseInSite(
|
||||||
|
params.organizationId,
|
||||||
|
params.siteId,
|
||||||
|
{
|
||||||
|
input: params.input,
|
||||||
|
output: params.output,
|
||||||
|
model: params.model,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...noCacheFetchOptions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const event of res) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let loggedServiceBinding = false;
|
let loggedServiceBinding = false;
|
||||||
@@ -554,17 +833,14 @@ let loggedServiceBinding = false;
|
|||||||
/**
|
/**
|
||||||
* Create a new API client.
|
* Create a new API client.
|
||||||
*/
|
*/
|
||||||
export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
|
export function apiClient(input: DataFetcherInput = { apiToken: null }) {
|
||||||
const { apiToken } = input;
|
const { apiToken } = input;
|
||||||
let serviceBinding: GitBookAPIServiceBinding | undefined;
|
let serviceBinding: GitBookAPIServiceBinding | undefined;
|
||||||
|
|
||||||
try {
|
const cloudflareContext = getCloudflareContext();
|
||||||
// HACK: This is a workaround to avoid webpack trying to bundle this cloudflare only module
|
if (cloudflareContext) {
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
const { env } = await import(
|
serviceBinding = cloudflareContext.env.GITBOOK_API as GitBookAPIServiceBinding | undefined;
|
||||||
/* webpackIgnore: true */ `${'__cloudflare:workers'.replaceAll('_', '')}`
|
|
||||||
);
|
|
||||||
serviceBinding = env.GITBOOK_API;
|
|
||||||
if (!loggedServiceBinding) {
|
if (!loggedServiceBinding) {
|
||||||
loggedServiceBinding = true;
|
loggedServiceBinding = true;
|
||||||
if (serviceBinding) {
|
if (serviceBinding) {
|
||||||
@@ -575,10 +851,6 @@ export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
|
|||||||
console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
|
console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = new GitBookAPI({
|
const api = new GitBookAPI({
|
||||||
@@ -590,3 +862,12 @@ export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
|
|||||||
|
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags from the API responses.
|
||||||
|
*/
|
||||||
|
function getCacheTagsFromResponse(response: HttpResponse<unknown, unknown>) {
|
||||||
|
const cacheTagHeader = response.headers.get('x-gitbook-cache-tag');
|
||||||
|
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
export function getExposableError(error: Error): DataFetcherErrorData {
|
||||||
if (error instanceof GitBookAPIError) {
|
if (error instanceof GitBookAPIError) {
|
||||||
|
if (error.code >= 500) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: error.errorMessage,
|
message: error.errorMessage,
|
||||||
@@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof DataFetcherError) {
|
if (error instanceof DataFetcherError) {
|
||||||
|
if (error.code >= 500) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export * from './pages';
|
|||||||
export * from './urls';
|
export * from './urls';
|
||||||
export * from './errors';
|
export * from './errors';
|
||||||
export * from './lookup';
|
export * from './lookup';
|
||||||
|
export * from './proxy';
|
||||||
|
export * from './visitor';
|
||||||
|
|||||||
@@ -1,48 +1,102 @@
|
|||||||
import { race, tryCatch } from '@/lib/async';
|
import { race, tryCatch } from '@/lib/async';
|
||||||
import { joinPath } from '@/lib/paths';
|
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||||
import { trace } from '@/lib/tracing';
|
import { trace } from '@/lib/tracing';
|
||||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
|
||||||
import { apiClient } from './api';
|
import { apiClient } from './api';
|
||||||
import { getExposableError } from './errors';
|
import { getExposableError } from './errors';
|
||||||
import type { DataFetcherResponse } from './types';
|
import type { DataFetcherResponse } from './types';
|
||||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||||
|
|
||||||
/**
|
interface LookupPublishedContentByUrlInput {
|
||||||
* Lookup a content by its URL using the GitBook API.
|
|
||||||
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
|
||||||
*/
|
|
||||||
export async function getPublishedContentByURL(input: {
|
|
||||||
url: string;
|
url: string;
|
||||||
visitorAuthToken: string | null;
|
|
||||||
redirectOnError: boolean;
|
redirectOnError: boolean;
|
||||||
apiToken: string | null;
|
apiToken: string | null;
|
||||||
|
visitorPayload: SiteVisitorPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
|
||||||
|
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||||
|
*/
|
||||||
|
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
|
||||||
|
return lookupPublishedContentByUrl({
|
||||||
|
url: input.url,
|
||||||
|
fetchLookupAPIResult: ({ url, signal }) => {
|
||||||
|
const api = apiClient({ apiToken: input.apiToken });
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'resolvePublishedContentByUrl',
|
||||||
|
name: url,
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
tryCatch(
|
||||||
|
api.urls.resolvePublishedContentByUrl(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
|
||||||
|
redirectOnError: input.redirectOnError,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
|
||||||
|
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||||
|
*
|
||||||
|
* @deprecated use resolvePublishedContentByUrl.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
|
||||||
|
return lookupPublishedContentByUrl({
|
||||||
|
url: input.url,
|
||||||
|
fetchLookupAPIResult: ({ url, signal }) => {
|
||||||
|
const api = apiClient({ apiToken: input.apiToken });
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'getPublishedContentByURL',
|
||||||
|
name: url,
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
tryCatch(
|
||||||
|
api.urls.getPublishedContentByUrl(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
|
||||||
|
redirectOnError: input.redirectOnError,
|
||||||
|
// @ts-expect-error - cacheVersion is not a real query param
|
||||||
|
cacheVersion: 'v2',
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
|
||||||
|
|
||||||
|
async function lookupPublishedContentByUrl(input: {
|
||||||
|
url: string;
|
||||||
|
fetchLookupAPIResult: (args: {
|
||||||
|
url: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
|
||||||
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||||
const lookupURL = new URL(input.url);
|
const lookupURL = new URL(input.url);
|
||||||
const url = stripURLSearch(lookupURL);
|
const url = stripURLSearch(lookupURL);
|
||||||
const lookup = getURLLookupAlternatives(url);
|
const lookup = getURLLookupAlternatives(url);
|
||||||
|
|
||||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||||
const api = await apiClient({ apiToken: input.apiToken });
|
const callResult = await input.fetchLookupAPIResult({
|
||||||
|
|
||||||
const callResult = await trace(
|
|
||||||
{
|
|
||||||
operation: 'getPublishedContentByURL',
|
|
||||||
name: alternative.url,
|
|
||||||
},
|
|
||||||
() =>
|
|
||||||
tryCatch(
|
|
||||||
api.urls.getPublishedContentByUrl(
|
|
||||||
{
|
|
||||||
url: alternative.url,
|
url: alternative.url,
|
||||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
|
||||||
redirectOnError: input.redirectOnError,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signal,
|
signal,
|
||||||
}
|
});
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (callResult.error) {
|
if (callResult.error) {
|
||||||
if (alternative.primary) {
|
if (alternative.primary) {
|
||||||
@@ -100,6 +154,7 @@ export async function getPublishedContentByURL(input: {
|
|||||||
|
|
||||||
const siteResult: PublishedSiteContentLookup = {
|
const siteResult: PublishedSiteContentLookup = {
|
||||||
...data,
|
...data,
|
||||||
|
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
|
||||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||||
...(changeRequest ? { changeRequest } : {}),
|
...(changeRequest ? { changeRequest } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it, mock } from 'bun:test';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
import { withCacheKey, withoutConcurrentExecution } from './memoize';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 support caching per request', async () => {
|
||||||
|
const fn = mock(async () => Math.random());
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a function by passing it a cache key that is computed from the function arguments.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
if (
|
||||||
|
typeof value === 'string' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
value === null ||
|
||||||
|
value === undefined
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(deepSortValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, subValue]) => {
|
||||||
|
return [key, deepSortValue(subValue)] as const;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
return a[0].localeCompare(b[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -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('/');
|
||||||
|
}
|
||||||
@@ -97,6 +97,15 @@ export interface GitBookDataFetcher {
|
|||||||
path: string;
|
path: string;
|
||||||
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
|
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the markdown content of a page by its path.
|
||||||
|
*/
|
||||||
|
getRevisionPageMarkdown(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
pageId: string;
|
||||||
|
}): Promise<DataFetcherResponse<string>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a document by its space ID and document ID.
|
* Get a document by its space ID and document ID.
|
||||||
*/
|
*/
|
||||||
@@ -170,4 +179,15 @@ export interface GitBookDataFetcher {
|
|||||||
integrationName: string;
|
integrationName: string;
|
||||||
request: api.RenderIntegrationUI;
|
request: api.RenderIntegrationUI;
|
||||||
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
|
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream an AI response.
|
||||||
|
*/
|
||||||
|
streamAIResponse(params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
input: api.AIMessageInput[];
|
||||||
|
output: api.AIOutputFormat;
|
||||||
|
model: api.AIModel;
|
||||||
|
}): 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.
|
* 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.
|
* 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 =
|
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
||||||
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
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.
|
* 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.
|
* Secret used to validate requests from the GitBook app.
|
||||||
*/
|
*/
|
||||||
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
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,81 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||||
|
|
||||||
|
describe('checkIsSizableImageURL', () => {
|
||||||
|
it('should return Skip for non-parsable URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Skip for non-http(s) URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip);
|
||||||
|
expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Skip for localhost URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe(
|
||||||
|
SizableImageAction.Skip
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Skip for GitBook image URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe(
|
||||||
|
SizableImageAction.Skip
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Resize for supported image extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.png')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Resize for URLs without extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Passthrough for unsupported image extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with query parameters correctly', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive for extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getExtension } from '@/lib/paths';
|
||||||
|
|
||||||
|
export enum SizableImageAction {
|
||||||
|
Resize = 'resize',
|
||||||
|
Skip = 'skip',
|
||||||
|
Passthrough = 'passthrough',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://developers.cloudflare.com/images/transform-images/#supported-input-formats
|
||||||
|
*/
|
||||||
|
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): SizableImageAction {
|
||||||
|
if (!URL.canParse(input)) {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(input);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
if (parsed.hostname === 'localhost') {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
if (parsed.pathname.includes('/~gitbook/image')) {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = getExtension(parsed.pathname).toLowerCase();
|
||||||
|
if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
|
||||||
|
// If no extension, we consider it resizable.
|
||||||
|
return SizableImageAction.Resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizableImageAction.Passthrough;
|
||||||
|
}
|
||||||
@@ -1,45 +1,22 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||||
import type { GitBookLinker } from '../links';
|
import type { GitBookLinker } from '../links';
|
||||||
|
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||||
|
import { getImageSize } from './resizer';
|
||||||
import { type SignatureVersion, generateImageSignature } from './signatures';
|
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||||
import type { ImageResizer } from './types';
|
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.
|
* Create an image resizer for a rendering context.
|
||||||
*/
|
*/
|
||||||
export function createImageResizer({
|
export function createImageResizer({
|
||||||
host,
|
imagesContextId,
|
||||||
linker,
|
linker,
|
||||||
}: {
|
}: {
|
||||||
/** The linker to use to create URLs. */
|
/** The linker to use to create URLs. */
|
||||||
linker: GitBookLinker;
|
linker: GitBookLinker;
|
||||||
/** The host name of the current site. */
|
/** The site identifier to use for verifying the image signature. */
|
||||||
host: string;
|
imagesContextId: string;
|
||||||
}): ImageResizer {
|
}): ImageResizer {
|
||||||
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
||||||
return createNoopImageResizer();
|
return createNoopImageResizer();
|
||||||
@@ -47,7 +24,7 @@ export function createImageResizer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getResizedImageURL: (urlInput) => {
|
getResizedImageURL: (urlInput) => {
|
||||||
if (!checkIsSizableImageURL(urlInput)) {
|
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +35,7 @@ export function createImageResizer({
|
|||||||
|
|
||||||
return async (options) => {
|
return async (options) => {
|
||||||
cachedSignature ??= await generateImageSignature({
|
cachedSignature ??= await generateImageSignature({
|
||||||
host,
|
imagesContextId,
|
||||||
url: urlInput,
|
url: urlInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +64,7 @@ export function createImageResizer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getImageSize: async (input, options) => {
|
getImageSize: async (input, options) => {
|
||||||
if (!checkIsSizableImageURL(input)) {
|
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
* 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
|
* We protect against it by converting to a special token, and then parsing
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 (isProxyRequest(url)) {
|
||||||
|
return getProxyRequestIdentifier(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.host;
|
||||||
|
}
|
||||||
@@ -2,3 +2,6 @@ export * from './types';
|
|||||||
export * from './createImageResizer';
|
export * from './createImageResizer';
|
||||||
export * from './signatures';
|
export * from './signatures';
|
||||||
export * from './utils';
|
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,72 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
|
||||||
|
import { SizableImageAction, 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) !== SizableImageAction.Resize) {
|
||||||
|
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 action = checkIsSizableImageURL(input);
|
||||||
|
if (action === SizableImageAction.Skip) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot resize this image, this function should have never been called on this url'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === SizableImageAction.Passthrough) {
|
||||||
|
return fetch(input, {
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
|
|||||||
|
|
||||||
type SignFnInput = {
|
type SignFnInput = {
|
||||||
url: string;
|
url: string;
|
||||||
host: string;
|
imagesContextId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SignFn = (input: SignFnInput) => MaybePromise<string>;
|
type SignFn = (input: SignFnInput) => MaybePromise<string>;
|
||||||
@@ -32,6 +32,11 @@ export async function verifyImageSignature(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
||||||
const generated = await generator(input);
|
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;
|
return generated === signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +65,14 @@ const generateSignatureV2: SignFn = async (input) => {
|
|||||||
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||||
const all = [
|
const all = [
|
||||||
input.url,
|
input.url,
|
||||||
input.host, // The hostname is used to avoid serving images from other sites on the same domain
|
input.imagesContextId, // The hostname is used to avoid serving images from other sites on the same domain
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(':');
|
.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
|
// Reused buffer for FNV-1a hashing in the v1 algorithm
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export function createLinker(
|
|||||||
},
|
},
|
||||||
|
|
||||||
toAbsoluteURL(absolutePath: string): string {
|
toAbsoluteURL(absolutePath: string): string {
|
||||||
|
// If the path is already a full URL, we return it as is.
|
||||||
|
if (URL.canParse(absolutePath)) {
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
if (!servedOn.host) {
|
if (!servedOn.host) {
|
||||||
return absolutePath;
|
return absolutePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
|
import { CustomizationThemeMode } from '@gitbook/api';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
import type { SiteURLData } from './context';
|
||||||
|
|
||||||
export enum MiddlewareHeaders {
|
export enum MiddlewareHeaders {
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
|
|||||||
* Get the site URL data from the middleware headers.
|
* Get the site URL data from the middleware headers.
|
||||||
* This function should only be called in a server action or a dynamic route.
|
* This function should only be called in a server action or a dynamic route.
|
||||||
*/
|
*/
|
||||||
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
|
export async function getSiteURLDataFromMiddleware(): Promise<SiteURLData> {
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,22 @@ import {
|
|||||||
type ResponseCookies,
|
type ResponseCookies,
|
||||||
getPathScopedCookieName,
|
getPathScopedCookieName,
|
||||||
getResponseCookiesForVisitorAuth,
|
getResponseCookiesForVisitorAuth,
|
||||||
getVisitorToken,
|
getVisitorData,
|
||||||
normalizeVisitorAuthURL,
|
normalizeVisitorAuthURL,
|
||||||
} from '@/lib/visitor-token';
|
} from '@/lib/visitors';
|
||||||
import { serveResizedImage } from '@/routes/image';
|
import { serveResizedImage } from '@/routes/image';
|
||||||
import {
|
import {
|
||||||
DataFetcherError,
|
DataFetcherError,
|
||||||
getPublishedContentByURL,
|
getPublishedContentByURL,
|
||||||
|
getVisitorAuthBasePath,
|
||||||
normalizeURL,
|
normalizeURL,
|
||||||
|
resolvePublishedContentByUrl,
|
||||||
throwIfDataError,
|
throwIfDataError,
|
||||||
} from '@v2/lib/data';
|
} from '@v2/lib/data';
|
||||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
||||||
|
import { getImageResizingContextId } from '@v2/lib/images';
|
||||||
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
||||||
|
import type { SiteURLData } from './lib/context';
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
|
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
|
||||||
@@ -31,6 +34,15 @@ export const config = {
|
|||||||
|
|
||||||
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary list of hosts to test adaptive content using the new resolution API.
|
||||||
|
*/
|
||||||
|
const ADAPTIVE_CONTENT_HOSTS = [
|
||||||
|
'docs.gitbook.com',
|
||||||
|
'adaptive-docs.gitbook-staging.com',
|
||||||
|
'enriched-content-playground.gitbook-staging.io',
|
||||||
|
];
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const requestURL = new URL(request.url);
|
const requestURL = new URL(request.url);
|
||||||
@@ -63,7 +75,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url: siteURL, mode } = match;
|
const { url: siteRequestURL, mode } = match;
|
||||||
|
const imagesContextId = getImageResizingContextId(siteRequestURL);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serve image resizing requests (all requests containing `/~gitbook/image`).
|
* Serve image resizing requests (all requests containing `/~gitbook/image`).
|
||||||
@@ -73,26 +86,31 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
|
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
|
||||||
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
|
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
|
||||||
*/
|
*/
|
||||||
if (siteURL.pathname.endsWith('/~gitbook/image')) {
|
if (siteRequestURL.pathname.endsWith('/~gitbook/image')) {
|
||||||
return await serveResizedImage(request, {
|
return await serveResizedImage(request, {
|
||||||
host: siteURL.host,
|
imagesContextId: imagesContextId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Detect and extract the visitor authentication token from the request
|
// Detect and extract the visitor authentication token from the request
|
||||||
//
|
//
|
||||||
// @ts-ignore - request typing
|
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({
|
||||||
const visitorToken = getVisitorToken({
|
|
||||||
cookies: request.cookies.getAll(),
|
cookies: request.cookies.getAll(),
|
||||||
url: siteURL,
|
url: siteRequestURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
const withAPIToken = async (apiToken: string | null) => {
|
const withAPIToken = async (apiToken: string | null) => {
|
||||||
|
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
|
||||||
|
? resolvePublishedContentByUrl
|
||||||
|
: getPublishedContentByURL;
|
||||||
const siteURLData = await throwIfDataError(
|
const siteURLData = await throwIfDataError(
|
||||||
getPublishedContentByURL({
|
resolve({
|
||||||
url: siteURL.toString(),
|
url: siteRequestURL.toString(),
|
||||||
visitorAuthToken: visitorToken?.token ?? null,
|
visitorPayload: {
|
||||||
|
jwtToken: visitorToken?.token ?? undefined,
|
||||||
|
unsignedClaims,
|
||||||
|
},
|
||||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||||
@@ -103,7 +121,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
apiToken,
|
apiToken,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const cookies: ResponseCookies = [];
|
|
||||||
|
const cookies: ResponseCookies = visitorParamsCookie
|
||||||
|
? [
|
||||||
|
// If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation.
|
||||||
|
visitorParamsCookie,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
//
|
//
|
||||||
// Handle redirects
|
// Handle redirects
|
||||||
@@ -134,20 +158,32 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
return NextResponse.redirect(siteURLData.redirect);
|
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
|
// 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
|
// The token is stored in a cookie that is set on the redirect response
|
||||||
//
|
//
|
||||||
const incomingURL = mode === 'url' ? requestURL : siteURL;
|
const incomingURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||||
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
if (incomingURLWithoutToken.toString() !== incomingURL.toString()) {
|
||||||
if (
|
|
||||||
requestURLWithoutToken !== incomingURL &&
|
|
||||||
requestURLWithoutToken.toString() !== incomingURL.toString()
|
|
||||||
) {
|
|
||||||
return writeResponseCookies(
|
return writeResponseCookies(
|
||||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
NextResponse.redirect(incomingURLWithoutToken.toString()),
|
||||||
cookies
|
cookies
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -160,19 +196,39 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
// (customization override, theme, etc)
|
// (customization override, theme, etc)
|
||||||
let routeType: 'dynamic' | 'static' = 'static';
|
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);
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||||
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${siteURLData.basePath}`);
|
requestHeaders.set(
|
||||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
MiddlewareHeaders.SiteURL,
|
||||||
|
`${siteCanonicalURL.origin}${siteURLData.basePath}`
|
||||||
|
);
|
||||||
|
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData));
|
||||||
|
|
||||||
// Preview of customization/theme
|
// Preview of customization/theme
|
||||||
const customization = siteURL.searchParams.get('customization');
|
const customization = siteRequestURL.searchParams.get('customization');
|
||||||
if (customization && validateSerializedCustomization(customization)) {
|
if (customization && validateSerializedCustomization(customization)) {
|
||||||
routeType = 'dynamic';
|
routeType = 'dynamic';
|
||||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||||
}
|
}
|
||||||
const theme = siteURL.searchParams.get('theme');
|
const theme = siteRequestURL.searchParams.get('theme');
|
||||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||||
routeType = 'dynamic';
|
routeType = 'dynamic';
|
||||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||||
@@ -188,7 +244,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
||||||
requestHeaders.set('origin', request.nextUrl.origin);
|
requestHeaders.set('origin', request.nextUrl.origin);
|
||||||
|
|
||||||
const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
|
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
|
||||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||||
siteURLData.pathname
|
siteURLData.pathname
|
||||||
);
|
);
|
||||||
@@ -199,7 +255,16 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
routeType,
|
routeType,
|
||||||
mode,
|
mode,
|
||||||
encodeURIComponent(siteURLWithoutProtocol),
|
encodeURIComponent(siteURLWithoutProtocol),
|
||||||
encodeURIComponent(rison.encode(siteURLData)),
|
encodeURIComponent(
|
||||||
|
rison.encode(
|
||||||
|
// rison can't encode undefined values
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(stableSiteURLData).filter(
|
||||||
|
([_, v]) => typeof v !== 'undefined'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
pathname,
|
pathname,
|
||||||
].join('/');
|
].join('/');
|
||||||
|
|
||||||
@@ -226,10 +291,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// For https://preview/<siteURL> requests,
|
// For https://preview/<siteURL> requests,
|
||||||
if (siteURL.hostname === 'preview') {
|
if (siteRequestURL.hostname === 'preview') {
|
||||||
return serveWithQueryAPIToken(
|
return serveWithQueryAPIToken(
|
||||||
// We scope the API token to the site ID.
|
// We scope the API token to the site ID.
|
||||||
`${siteURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
|
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
|
||||||
request,
|
request,
|
||||||
withAPIToken
|
withAPIToken
|
||||||
);
|
);
|
||||||
@@ -382,13 +447,26 @@ function encodePathInSiteContent(rawPathname: string): {
|
|||||||
return { pathname };
|
return { pathname };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
|
||||||
|
if (pathname.match(/\.md$/)) {
|
||||||
|
const pagePathWithoutMD = pathname.slice(0, -3);
|
||||||
|
return {
|
||||||
|
pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`,
|
||||||
|
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
|
||||||
|
routeType: 'static',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
switch (pathname) {
|
switch (pathname) {
|
||||||
case '~gitbook/icon':
|
case '~gitbook/icon':
|
||||||
|
return { pathname };
|
||||||
case 'llms.txt':
|
case 'llms.txt':
|
||||||
case 'sitemap.xml':
|
case 'sitemap.xml':
|
||||||
case 'sitemap-pages.xml':
|
case 'sitemap-pages.xml':
|
||||||
case 'robots.txt':
|
case 'robots.txt':
|
||||||
return { pathname };
|
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
|
||||||
|
// as they only depend on the site structure / pages.
|
||||||
|
return { pathname, routeType: 'static' };
|
||||||
case '~gitbook/pdf':
|
case '~gitbook/pdf':
|
||||||
// PDF routes are always dynamic as they depend on the search params.
|
// PDF routes are always dynamic as they depend on the search params.
|
||||||
return { pathname, routeType: 'dynamic' };
|
return { pathname, routeType: 'dynamic' };
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "gitbook-open-v2",
|
||||||
|
"compatibility_date": "2025-04-14",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat",
|
||||||
|
"allow_importable_env",
|
||||||
|
"global_fetch_strictly_public"
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "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": {
|
||||||
|
"vars": {
|
||||||
|
// This is a bit misleading, but it means that we can have 100 concurrent revalidations
|
||||||
|
// This means that we'll have up to 20 durable objects instance running at the same time
|
||||||
|
"MAX_REVALIDATE_CONCURRENCY": "20"
|
||||||
|
},
|
||||||
|
"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,182 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.12.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 8339e91: Fix images in reusable content across spaces.
|
||||||
|
- 326e28e: Design tweaks to code blocks and OpenAPI pages
|
||||||
|
- 3119066: Add support for reusable content across spaces.
|
||||||
|
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- c4ebb3f: Fix openapi-select hover in responses
|
||||||
|
- aed79fd: Decrease rounding of header logo
|
||||||
|
- 42ca7e1: Fix openapi CR preview
|
||||||
|
- e6ddc0f: Fix URL in sitemap
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- 5d504ff: Fix resolution of links in reusable contents
|
||||||
|
- 95a1f65: Better print layouts: wrap code blocks & force table column auto-sizing
|
||||||
|
- 0499966: Fix invalid sitemap.xml generated with relative URLs instead of absolute ones
|
||||||
|
- 2a805cc: Change OpenAPI schema-optional from `info` to `tint` color
|
||||||
|
- 580101d: Fix schemas disclosure label causing client error
|
||||||
|
- 12a455d: Fix OpenAPI layout issues
|
||||||
|
- 97b7c79: Increase logging around caching behaviour causing page crashes.
|
||||||
|
- 373f18f: Prevent section group popovers from opening on click
|
||||||
|
- 3f29206: Update the regex for validating site redirect
|
||||||
|
- 0c973a3: Always link main logo to the root of the site
|
||||||
|
- ae5f1ab: Change `Dropdown`s to use Radix's `DropdownMenu`
|
||||||
|
- 0e201d5: Add border to filled sidebar on gradient theme
|
||||||
|
- dd043df: Revert investigation work around URL caches.
|
||||||
|
- 89a5816: Fix OpenAPI disclosure label ("Show properties") misalignment on mobile
|
||||||
|
- Updated dependencies [c3f6b8c]
|
||||||
|
- Updated dependencies [d00dc8c]
|
||||||
|
- Updated dependencies [42ca7e1]
|
||||||
|
- Updated dependencies [326e28e]
|
||||||
|
- Updated dependencies [5e975ab]
|
||||||
|
- Updated dependencies [f7a3470]
|
||||||
|
- Updated dependencies [580101d]
|
||||||
|
- Updated dependencies [20ebecb]
|
||||||
|
- Updated dependencies [80cb52a]
|
||||||
|
- Updated dependencies [cb5598d]
|
||||||
|
- Updated dependencies [c6637b0]
|
||||||
|
- Updated dependencies [a3ec264]
|
||||||
|
- @gitbook/colors@0.3.3
|
||||||
|
- @gitbook/openapi-parser@2.1.4
|
||||||
|
- @gitbook/react-openapi@1.3.0
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- da7b369: Fix missing headers in OpenAPIResponses
|
||||||
|
- 139a805: Fix OpenAPI enum display
|
||||||
|
- Updated dependencies [da7b369]
|
||||||
|
- Updated dependencies [da485f5]
|
||||||
|
- Updated dependencies [139a805]
|
||||||
|
- @gitbook/react-openapi@1.1.9
|
||||||
|
|
||||||
|
## 0.9.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
|
||||||
|
- 7d0b422: Handle grouped OpenAPISchemas
|
||||||
|
- Updated dependencies [7d0b422]
|
||||||
|
- Updated dependencies [fb90eb0]
|
||||||
|
- @gitbook/react-openapi@1.1.8
|
||||||
|
- @gitbook/colors@0.3.1
|
||||||
|
|
||||||
|
## 0.9.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 77fd393: Track event when visitor is opening a search result.
|
||||||
|
- d70d566: Support site announcement banner
|
||||||
|
- 77fd393: Track event when clicking announcement banner link.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- e84a46a: Fix OpenAPI tabs indicator overflow
|
||||||
|
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
|
||||||
|
- 434af90: Fix image resizing when using the proxy feature in a site.
|
||||||
|
- c756761: Add breadcrumbs to search results
|
||||||
|
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
|
||||||
|
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
|
||||||
|
- 1505ddb: Fix multiple request examples selector not showing
|
||||||
|
- 61db166: Add OpenAPI write-only indicator
|
||||||
|
- 6f71da8: Fix padding in schemas
|
||||||
|
- fa91eb7: Fix PDF generation when user has dark mode configured.
|
||||||
|
- 5b1e01c: Support for x-stability property
|
||||||
|
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
|
||||||
|
- d236bf0: Fix flash when loading sites with dark mode as default theme
|
||||||
|
- cd99ed5: Fix spec properties rendering and missing keys
|
||||||
|
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
|
||||||
|
- e9fa50d: Trim the search query to avoid showing a loading state when typing
|
||||||
|
- Updated dependencies [bd35348]
|
||||||
|
- Updated dependencies [ae78fc5]
|
||||||
|
- Updated dependencies [7bb37c7]
|
||||||
|
- Updated dependencies [373183a]
|
||||||
|
- Updated dependencies [1505ddb]
|
||||||
|
- Updated dependencies [61db166]
|
||||||
|
- Updated dependencies [5b1e01c]
|
||||||
|
- Updated dependencies [cd99ed5]
|
||||||
|
- Updated dependencies [813b2af]
|
||||||
|
- Updated dependencies [a25fded]
|
||||||
|
- @gitbook/react-openapi@1.1.7
|
||||||
|
- @gitbook/openapi-parser@2.1.2
|
||||||
|
|
||||||
## 0.8.2
|
## 0.8.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
@@ -433,7 +610,7 @@
|
|||||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||||
- 3996110: Optimize images rendered in community ads
|
- 3996110: Optimize images rendered in community ads
|
||||||
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||||
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
- 5096f7f: Disable KV cache for gitbook.com/docs as a test, also disable it for change-request to improve consistency
|
||||||
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||||
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||||
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
|
|||||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: 'Nexthink',
|
// name: 'Nexthink',
|
||||||
contentBaseURL: 'https://docs.nexthink.com',
|
// contentBaseURL: 'https://docs.nexthink.com',
|
||||||
tests: [
|
// tests: [
|
||||||
{
|
// {
|
||||||
name: 'Home',
|
// name: 'Home',
|
||||||
url: '/',
|
// url: '/',
|
||||||
screenshot: { waitForTOCScrolling: false },
|
// screenshot: { waitForTOCScrolling: false },
|
||||||
run: waitForCookiesDialog,
|
// run: waitForCookiesDialog,
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||||
contentBaseURL: 'https://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',
|
contentBaseURL: 'https://book.character.ai',
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'docs.tradeonnova.io',
|
|
||||||
contentBaseURL: 'https://docs.tradeonnova.io',
|
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'azcoiner.gitbook.io',
|
name: 'azcoiner.gitbook.io',
|
||||||
contentBaseURL: 'https://azcoiner.gitbook.io',
|
contentBaseURL: 'https://azcoiner.gitbook.io',
|
||||||
@@ -162,11 +157,11 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://wiki.redmodding.org',
|
contentBaseURL: 'https://wiki.redmodding.org',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: 'docs.cherry-ai.com',
|
// name: 'docs.cherry-ai.com',
|
||||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
// contentBaseURL: 'https://docs.cherry-ai.com',
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: 'docs.snyk.io',
|
name: 'docs.snyk.io',
|
||||||
contentBaseURL: 'https://docs.snyk.io',
|
contentBaseURL: 'https://docs.snyk.io',
|
||||||
@@ -244,6 +239,15 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://docs.fluentbit.io',
|
contentBaseURL: 'https://docs.fluentbit.io',
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
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);
|
runTestCases(testCases);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
VISITOR_TOKEN_COOKIE,
|
VISITOR_TOKEN_COOKIE,
|
||||||
getVisitorAuthCookieName,
|
getVisitorAuthCookieName,
|
||||||
getVisitorAuthCookieValue,
|
getVisitorAuthCookieValue,
|
||||||
} from '@/lib/visitor-token';
|
} from '@/lib/visitors';
|
||||||
|
|
||||||
import { getSiteAPIToken } from '../tests/utils';
|
import { getSiteAPIToken } from '../tests/utils';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
headerLinks,
|
headerLinks,
|
||||||
runTestCases,
|
runTestCases,
|
||||||
waitForCookiesDialog,
|
waitForCookiesDialog,
|
||||||
|
waitForNotFound,
|
||||||
} from './util';
|
} from './util';
|
||||||
|
|
||||||
const testCases: TestsCase[] = [
|
const testCases: TestsCase[] = [
|
||||||
@@ -110,24 +111,24 @@ const testCases: TestsCase[] = [
|
|||||||
name: 'Customized variant titles are displayed',
|
name: 'Customized variant titles are displayed',
|
||||||
url: '',
|
url: '',
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = page
|
const spaceDropdown = page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.locator('visible=true');
|
||||||
await spaceDrowpdown.click();
|
await spaceDropdown.click();
|
||||||
|
|
||||||
const variantSelectionDropdown = page.locator(
|
const variantSelectionDropdown = page.locator(
|
||||||
'css=[data-testid="space-dropdown-button"] + div'
|
'css=[data-testid="dropdown-menu"]'
|
||||||
);
|
);
|
||||||
// the customized space title
|
// the customized space title
|
||||||
await expect(
|
await expect(
|
||||||
variantSelectionDropdown.getByRole('link', {
|
variantSelectionDropdown.getByRole('menuitem', {
|
||||||
name: 'Multi-Variants',
|
name: 'Multi-Variants',
|
||||||
})
|
})
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// the NON-customized space title
|
// the NON-customized space title
|
||||||
await expect(
|
await expect(
|
||||||
variantSelectionDropdown.getByRole('link', {
|
variantSelectionDropdown.getByRole('menuitem', {
|
||||||
name: 'RFCs',
|
name: 'RFCs',
|
||||||
})
|
})
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -144,14 +145,17 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'api-multi-versions/reference/api-reference/pets',
|
url: 'api-multi-versions/reference/api-reference/pets',
|
||||||
screenshot: false,
|
screenshot: false,
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = await page
|
const spaceDropdown = await page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.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
|
// Click the second variant in the dropdown
|
||||||
await page
|
await variantSelectionDropdown
|
||||||
.getByRole('link', {
|
.getByRole('menuitem', {
|
||||||
name: '2.0',
|
name: '2.0',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
@@ -167,14 +171,18 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||||
screenshot: false,
|
screenshot: false,
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = await page
|
const spaceDropdown = await page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.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
|
// Click the second variant in the dropdown
|
||||||
await page
|
await variantSelectionDropdown
|
||||||
.getByRole('link', {
|
.getByRole('menuitem', {
|
||||||
name: '2.0',
|
name: '2.0',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
@@ -204,14 +212,18 @@ const testCases: TestsCase[] = [
|
|||||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||||
},
|
},
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = await page
|
const spaceDropdown = await page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.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
|
// Click the second variant in the dropdown
|
||||||
await page
|
await variantSelectionDropdown
|
||||||
.getByRole('link', {
|
.getByRole('menuitem', {
|
||||||
name: '2.0',
|
name: '2.0',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
@@ -257,7 +269,7 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'GitBook',
|
name: 'GitBook',
|
||||||
contentBaseURL: 'https://docs.gitbook.com',
|
contentBaseURL: 'https://gitbook.com/docs/',
|
||||||
tests: [
|
tests: [
|
||||||
{
|
{
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
@@ -308,6 +320,18 @@ const testCases: TestsCase[] = [
|
|||||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||||||
run: waitForCookiesDialog,
|
run: waitForCookiesDialog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Invalid revision',
|
||||||
|
url: '~/revisions/idnotfound/blocks/headings',
|
||||||
|
run: waitForNotFound,
|
||||||
|
screenshot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Invalid change request',
|
||||||
|
url: '~/changes/idnotfound/blocks/headings',
|
||||||
|
run: waitForNotFound,
|
||||||
|
screenshot: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -390,6 +414,59 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Markdown page',
|
||||||
|
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||||
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Text page',
|
||||||
|
url: 'text-page.md',
|
||||||
|
screenshot: false,
|
||||||
|
run: async (_page, response) => {
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
name: 'Content tests',
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
@@ -808,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',
|
name: 'Site Redirects with sections',
|
||||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type CustomizationThemedColor,
|
type CustomizationThemedColor,
|
||||||
type SiteCustomizationSettings,
|
type SiteCustomizationSettings,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import { type BrowserContext, type Page, expect, test } from '@playwright/test';
|
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
|
||||||
import deepMerge from 'deepmerge';
|
import deepMerge from 'deepmerge';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import type { DeepPartial } from 'ts-essentials';
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
@@ -33,7 +33,11 @@ export interface Test {
|
|||||||
/**
|
/**
|
||||||
* Test to run
|
* Test to run
|
||||||
*/
|
*/
|
||||||
run?: (page: Page) => Promise<unknown>;
|
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* Mode for the test.
|
||||||
|
*/
|
||||||
|
mode?: 'page' | 'image';
|
||||||
/**
|
/**
|
||||||
* Whether the test should be fullscreened during testing.
|
* Whether the test should be fullscreened during testing.
|
||||||
*/
|
*/
|
||||||
@@ -138,6 +142,11 @@ export async function waitForCookiesDialog(page: Page) {
|
|||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||||
|
expect(response).not.toBeNull();
|
||||||
|
expect(response?.status()).toBe(404);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform test cases into Playwright tests and run it.
|
* Transform test cases into Playwright tests and run it.
|
||||||
*/
|
*/
|
||||||
@@ -149,6 +158,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
|
|
||||||
test.describe(testCase.name, () => {
|
test.describe(testCase.name, () => {
|
||||||
for (const testEntry of testCase.tests) {
|
for (const testEntry of testCase.tests) {
|
||||||
|
const { mode = 'page' } = testEntry;
|
||||||
const testFn = testEntry.only ? test.only : test;
|
const testFn = testEntry.only ? test.only : test;
|
||||||
testFn(testEntry.name, async ({ page, context }) => {
|
testFn(testEntry.name, async ({ page, context }) => {
|
||||||
const testEntryPathname =
|
const testEntryPathname =
|
||||||
@@ -158,6 +168,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
||||||
)
|
)
|
||||||
: getTestURL(testEntryPathname);
|
: getTestURL(testEntryPathname);
|
||||||
|
|
||||||
if (testEntry.cookies) {
|
if (testEntry.cookies) {
|
||||||
await context.addCookies(
|
await context.addCookies(
|
||||||
testEntry.cookies.map((cookie) => ({
|
testEntry.cookies.map((cookie) => ({
|
||||||
@@ -183,13 +194,21 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(url);
|
const response = await page.goto(url);
|
||||||
if (testEntry.run) {
|
if (testEntry.run) {
|
||||||
await testEntry.run(page);
|
await testEntry.run(page, response);
|
||||||
}
|
}
|
||||||
const screenshotOptions = testEntry.screenshot;
|
const screenshotOptions = testEntry.screenshot;
|
||||||
if (screenshotOptions !== false) {
|
if (screenshotOptions !== false) {
|
||||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
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'],
|
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||||
argosCSS: `
|
argosCSS: `
|
||||||
/* Hide Intercom */
|
/* Hide Intercom */
|
||||||
@@ -201,13 +220,14 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
fullPage: testEntry.fullPage ?? false,
|
fullPage: testEntry.fullPage ?? false,
|
||||||
beforeScreenshot: async ({ runStabilization }) => {
|
beforeScreenshot: async ({ runStabilization }) => {
|
||||||
await runStabilization();
|
await runStabilization();
|
||||||
await waitForIcons(page);
|
|
||||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||||
await waitForTOCScrolling(page);
|
await waitForTOCScrolling(page);
|
||||||
}
|
}
|
||||||
|
await waitForIcons(page);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -271,6 +291,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
internationalization: {
|
internationalization: {
|
||||||
locale: CustomizationLocale.En,
|
locale: CustomizationLocale.En,
|
||||||
},
|
},
|
||||||
|
insights: {
|
||||||
|
trackingCookie: true,
|
||||||
|
},
|
||||||
favicon: {},
|
favicon: {},
|
||||||
header: {
|
header: {
|
||||||
preset: CustomizationHeaderPreset.Default,
|
preset: CustomizationHeaderPreset.Default,
|
||||||
@@ -323,61 +346,78 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
*/
|
*/
|
||||||
async function waitForIcons(page: Page) {
|
async function waitForIcons(page: Page) {
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const urls = new Set<string>();
|
const urlStates: Record<
|
||||||
|
string,
|
||||||
|
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||||
|
> = (window as any).__ICONS_STATES__ || {};
|
||||||
|
(window as any).__ICONS_STATES__ = urlStates;
|
||||||
|
|
||||||
|
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch SVG: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgText = await response.text();
|
||||||
|
const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22');
|
||||||
|
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encoded}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUrl = (url: string) => {
|
||||||
|
// Mark the URL as pending.
|
||||||
|
urlStates[url] = { state: 'pending', uri: null };
|
||||||
|
fetchSvgAsDataUri(url).then((uri) => {
|
||||||
|
urlStates[url] = { state: 'loaded', uri };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||||
const results = icons.map((icon) => {
|
const results = icons.map((icon) => {
|
||||||
if (!(icon instanceof SVGElement)) {
|
if (!(icon instanceof SVGElement)) {
|
||||||
throw new Error('Icon is not an 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.
|
// Ignore icons that are not visible.
|
||||||
if (!icon.checkVisibility()) {
|
if (!icon.checkVisibility()) {
|
||||||
return true;
|
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")
|
// 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 maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
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 is invalid we throw an error.
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('No mask-image');
|
throw new Error('No mask-image');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the URL is already loaded, we just mark it as loaded.
|
// If the URL is already queued for loading, we return the state.
|
||||||
if (urls.has(url)) {
|
if (urlStates[url]) {
|
||||||
icon.dataset.loadingState = 'loaded';
|
if (urlStates[url].state === 'loaded') {
|
||||||
return true;
|
icon.setAttribute('data-argos-state', 'pending');
|
||||||
|
icon.style.maskImage = `url("${urlStates[url].uri}")`;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
icon.setAttribute('data-argos-state', 'loaded');
|
||||||
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the icon as pending and load the image.
|
return false;
|
||||||
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;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module.exports = {
|
|||||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||||
|
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||||
},
|
},
|
||||||
|
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.8.2",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "^0.115.0",
|
||||||
"@gitbook/cache-do": "workspace:*",
|
"@gitbook/cache-do": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
@@ -27,17 +27,18 @@
|
|||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@upstash/redis": "^1.27.1",
|
"ai": "^4.2.2",
|
||||||
"ai": "^4.1.46",
|
|
||||||
"ajv": "^8.12.0",
|
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "^1.2.1",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"event-iterator": "^2.0.0",
|
||||||
"framer-motion": "^10.16.14",
|
"framer-motion": "^10.16.14",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsontoxml": "^1.0.1",
|
"jsontoxml": "^1.0.1",
|
||||||
@@ -45,16 +46,17 @@
|
|||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"memoizee": "^0.4.15",
|
"memoizee": "^0.4.17",
|
||||||
"next": "14.2.25",
|
"next": "14.2.26",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.0",
|
"p-map": "^7.0.0",
|
||||||
"parse-cache-control": "^1.0.1",
|
"parse-cache-control": "^1.0.1",
|
||||||
"react": "18.3.1",
|
"partial-json": "^0.1.7",
|
||||||
"react-dom": "18.3.1",
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -68,11 +70,14 @@
|
|||||||
"tailwind-shades": "^1.1.2",
|
"tailwind-shades": "^1.1.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"usehooks-ts": "^3.1.0"
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"zod": "^3.24.2",
|
||||||
|
"zod-to-json-schema": "^3.24.5",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^4.3.0",
|
"@argos-ci/playwright": "^5.0.3",
|
||||||
"@cloudflare/next-on-pages": "1.13.7",
|
"@cloudflare/next-on-pages": "1.13.12",
|
||||||
"@cloudflare/workers-types": "^4.20241230.0",
|
"@cloudflare/workers-types": "^4.20241230.0",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.51.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
# GitBook immutable static assets
|
# GitBook immutable static assets
|
||||||
|
# Duplicated from next.config.mjs until OpenNext supports generating static headers
|
||||||
/~gitbook/static/*
|
/~gitbook/static/*
|
||||||
cache-control: public,max-age=31536000,immutable
|
cache-control: public,max-age=31536000,immutable
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
/_next/static/*
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ interface JsonBody {
|
|||||||
* The body should be a JSON with { tags: string[] }
|
* The body should be a JSON with { tags: string[] }
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const json = (await req.json()) as JsonBody;
|
let json: JsonBody;
|
||||||
|
|
||||||
|
try {
|
||||||
|
json = await req.json();
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `invalid json body: ${err}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!json.tags || !Array.isArray(json.tags)) {
|
if (!json.tags || !Array.isArray(json.tags)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -25,10 +33,18 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await revalidateTags(json.tags);
|
const result = await revalidateTags(json.tags);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
stats: result.stats,
|
stats: result.stats,
|
||||||
});
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `${err}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
|
|||||||
export function PageClientLayout(props: { withSections?: boolean }) {
|
export function PageClientLayout(props: { withSections?: boolean }) {
|
||||||
// We use this hook in the page layout to ensure the elements for the blocks
|
// We use this hook in the page layout to ensure the elements for the blocks
|
||||||
// are rendered before we scroll to a hash or to the top of the page
|
// are rendered before we scroll to a hash or to the top of the page
|
||||||
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
|
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
|
||||||
|
|
||||||
useStripFallbackQueryParam();
|
useStripFallbackQueryParam();
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
export default function ErrorPage(props: {
|
export default function ErrorPage(props: {
|
||||||
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
|
|||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
label={tString(language, 'unexpected_error_retry')}
|
||||||
{t(language, 'unexpected_error_retry')}
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export function AIPageLinkSummary(props: {
|
||||||
|
targetSpaceId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
linkPreview?: string;
|
||||||
|
linkTitle?: string;
|
||||||
|
showTrademark: boolean;
|
||||||
|
}) {
|
||||||
|
const { targetSpaceId, targetPageId, linkPreview, linkTitle, showTrademark = true } = props;
|
||||||
|
|
||||||
|
const currentPage = usePageContext();
|
||||||
|
const language = useLanguage();
|
||||||
|
const visitedPages = useVisitedPages((state) => state.pages);
|
||||||
|
const { summary, streamSummary } = useSummaries(
|
||||||
|
useShallow((state) => {
|
||||||
|
return {
|
||||||
|
summary: state.getSummary({ targetSpaceId, targetPageId }),
|
||||||
|
streamSummary: state.streamSummary,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
streamSummary({
|
||||||
|
currentSpaceId: currentPage.spaceId,
|
||||||
|
currentPageId: currentPage.pageId,
|
||||||
|
currentPageTitle: currentPage.title,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentPage.pageId,
|
||||||
|
currentPage.spaceId,
|
||||||
|
currentPage.title,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
streamSummary,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shimmerBlocks = [
|
||||||
|
'w-[20%] [animation-delay:-1s]',
|
||||||
|
'w-[35%] [animation-delay:-0.8s]',
|
||||||
|
'w-[25%] [animation-delay:-0.6s]',
|
||||||
|
'w-[10%] [animation-delay:-0.4s]',
|
||||||
|
'w-[40%] [animation-delay:-0.2s]',
|
||||||
|
'w-[30%] [animation-delay:0s]',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={!summary || summary.length === 0} />
|
||||||
|
) : (
|
||||||
|
<Icon icon="sparkle" className="size-3" />
|
||||||
|
)}
|
||||||
|
<h6 className="text-tint">{t(language, 'link_tooltip_ai_summary')}</h6>
|
||||||
|
</div>
|
||||||
|
{summary.length > 0 ? (
|
||||||
|
<p className="animate-fadeIn">{summary}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{shimmerBlocks.map((block, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${block}`}
|
||||||
|
className={`${block} h-4 animate-pulse rounded straight-corners:rounded-none bg-tint-active`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary.length > 0 ? (
|
||||||
|
<div className="animate-fadeIn text-tint-subtle text-xs">
|
||||||
|
{t(language, 'link_tooltip_ai_summary_description')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './AIPageLinkSummary';
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use server';
|
||||||
|
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
|
||||||
|
import type { GitBookBaseContext } from '@v2/lib/context';
|
||||||
|
import { EventIterator } from 'event-iterator';
|
||||||
|
import type { MaybePromise } from 'p-map';
|
||||||
|
import * as partialJson from 'partial-json';
|
||||||
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest value from a stream and the response id.
|
||||||
|
*/
|
||||||
|
export async function generate<T>(
|
||||||
|
promise: MaybePromise<{
|
||||||
|
stream: EventIterator<T>;
|
||||||
|
response: Promise<{ responseId: string }>;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const input = await promise;
|
||||||
|
let value: T | undefined;
|
||||||
|
|
||||||
|
for await (const event of input.stream) {
|
||||||
|
value = event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { responseId } = await input.response;
|
||||||
|
return {
|
||||||
|
responseId,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the generation of an object using the AI.
|
||||||
|
*/
|
||||||
|
export async function streamGenerateObject<T>(
|
||||||
|
context: GitBookBaseContext,
|
||||||
|
{
|
||||||
|
organizationId,
|
||||||
|
siteId,
|
||||||
|
}: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema,
|
||||||
|
messages,
|
||||||
|
model = AIModel.Fast,
|
||||||
|
}: {
|
||||||
|
schema: z.ZodSchema<T>;
|
||||||
|
messages: AIMessageInput[];
|
||||||
|
model?: AIModel;
|
||||||
|
previousResponseId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const rawStream = context.dataFetcher.streamAIResponse({
|
||||||
|
organizationId,
|
||||||
|
siteId,
|
||||||
|
input: messages,
|
||||||
|
output: {
|
||||||
|
type: 'object',
|
||||||
|
schema: zodToJsonSchema(schema),
|
||||||
|
},
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
|
||||||
|
let json = '';
|
||||||
|
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
|
||||||
|
if (event.type === 'response_object') {
|
||||||
|
json += event.jsonChunk;
|
||||||
|
|
||||||
|
const parsed = partialJson.parse(json, partialJson.ALL);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a stream from the API to extract the responseId.
|
||||||
|
*/
|
||||||
|
function parseResponse<T>(
|
||||||
|
responseStream: EventIterator<AIStreamResponse>,
|
||||||
|
parse: (response: AIStreamResponse) => T | undefined
|
||||||
|
): {
|
||||||
|
stream: EventIterator<T>;
|
||||||
|
response: Promise<{ responseId: string }>;
|
||||||
|
} {
|
||||||
|
let resolveResponse: (value: { responseId: string }) => void;
|
||||||
|
const response = new Promise<{ responseId: string }>((resolve) => {
|
||||||
|
resolveResponse = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = new EventIterator<T>((queue) => {
|
||||||
|
(async () => {
|
||||||
|
let foundResponse = false;
|
||||||
|
|
||||||
|
for await (const event of responseStream) {
|
||||||
|
if (event.type === 'response_finish') {
|
||||||
|
foundResponse = true;
|
||||||
|
resolveResponse({ responseId: event.responseId });
|
||||||
|
} else {
|
||||||
|
const parsed = parse(event);
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
queue.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundResponse) {
|
||||||
|
throw new Error('No response found');
|
||||||
|
}
|
||||||
|
})().then(
|
||||||
|
() => {
|
||||||
|
queue.stop();
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
queue.fail(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stream, response };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './streamLinkPageSummary';
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use server';
|
||||||
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
|
import { getV1BaseContext } from '@/lib/v1';
|
||||||
|
import { isV2 } from '@/lib/v2';
|
||||||
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
|
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||||
|
import { 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* streamLinkPageSummary({
|
||||||
|
currentSpaceId,
|
||||||
|
currentPageId,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
}: {
|
||||||
|
currentSpaceId: string;
|
||||||
|
currentPageId: string;
|
||||||
|
currentPageTitle: string;
|
||||||
|
targetSpaceId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
linkPreview?: string;
|
||||||
|
linkTitle?: string;
|
||||||
|
visitedPages?: Array<{ spaceId: string; pageId: string }>;
|
||||||
|
}) {
|
||||||
|
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
|
const { stream } = await streamGenerateObject(
|
||||||
|
baseContext,
|
||||||
|
{
|
||||||
|
organizationId: siteURLData.organization,
|
||||||
|
siteId: siteURLData.site,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema: z.object({
|
||||||
|
highlight: z
|
||||||
|
.string()
|
||||||
|
.describe('The reason why the user should read the target page.'),
|
||||||
|
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||||
|
}),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 1. Role
|
||||||
|
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
|
||||||
|
|
||||||
|
# 2. Task
|
||||||
|
Extract a contextually-relevant fact that:
|
||||||
|
- Directly answers the specific need or question implied by the link's placement
|
||||||
|
- States a capability, limitation, or specification from the target page
|
||||||
|
- Connects precisely to the user's current paragraph or sentence
|
||||||
|
- Completes the user's understanding based on what they're currently reading
|
||||||
|
|
||||||
|
# 3. Instructions
|
||||||
|
1. First, identify the exact need, question, or gap in the current paragraph where the link appears
|
||||||
|
2. Find the specific fact in the target page that addresses this exact contextual need
|
||||||
|
3. Ensure the fact relates directly to the context of the paragraph containing the link
|
||||||
|
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
|
||||||
|
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 4. Current page
|
||||||
|
The content of the current page is:`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
spaceId: currentSpaceId,
|
||||||
|
pageId: currentPageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...(visitedPages
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: '# 5. Previous pages',
|
||||||
|
},
|
||||||
|
...visitedPages.map(({ spaceId, pageId }) => ({
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `## Page ${pageId}`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 6. Target page
|
||||||
|
The content of the target page is:`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
spaceId: targetSpaceId,
|
||||||
|
pageId: targetPageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 7. Link preview
|
||||||
|
The content of the link preview is:
|
||||||
|
> ${linkPreview}
|
||||||
|
> Page ID: ${targetPageId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 8. Guidelines & Examples
|
||||||
|
ALWAYS:
|
||||||
|
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
|
||||||
|
- ALWAYS connect target page information specifically to the current paragraph context
|
||||||
|
- ALWAYS focus on the gap in knowledge that the link is meant to fill
|
||||||
|
- ALWAYS consider user's navigation history to ensure contextual continuity
|
||||||
|
- ALWAYS use action verbs like "click", "select", "use", "create", "enable"
|
||||||
|
|
||||||
|
NEVER:
|
||||||
|
- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly.
|
||||||
|
- NEVER select general facts unrelated to the specific link context
|
||||||
|
- NEVER ignore the specific context where the link appears
|
||||||
|
- NEVER repeat the same fact in different words
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once."
|
||||||
|
Preview: "Multiple Select: Select multiple content blocks at once."
|
||||||
|
✓ "Shift selects content between two points, useful for reorganizing your current heading structure."
|
||||||
|
✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks."
|
||||||
|
|
||||||
|
Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]."
|
||||||
|
Preview: "Change Requests: Collaborative content editing workflow."
|
||||||
|
✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions."
|
||||||
|
✗ "Each reviewer receives an email notification and can approve or request changes."
|
||||||
|
|
||||||
|
Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]."
|
||||||
|
Preview: "Live Edit: Real-time collaborative editing."
|
||||||
|
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
|
||||||
|
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.User,
|
||||||
|
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
|
||||||
|
},
|
||||||
|
].filter(filterOutNullable),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const value of stream) {
|
||||||
|
const highlight = value.highlight;
|
||||||
|
if (!highlight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { resolveContentRef } from '@/lib/references';
|
||||||
|
import type { GitBookSiteContext } from '@v2/lib/context';
|
||||||
|
import { AnnouncementBanner } from './AnnouncementBanner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side component to resolve content refs and pass down to client-side component
|
||||||
|
*/
|
||||||
|
export async function Announcement(props: {
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
}) {
|
||||||
|
const { context } = props;
|
||||||
|
const { customization } = context;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!customization.announcement ||
|
||||||
|
!customization.announcement.enabled ||
|
||||||
|
!customization.announcement.message
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedContentRef = customization.announcement?.link
|
||||||
|
? await resolveContentRef(customization.announcement?.link?.to, context)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnnouncementBanner
|
||||||
|
announcement={customization.announcement}
|
||||||
|
contentRef={resolvedContentRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as storage from '@/lib/local-storage';
|
||||||
|
import type { ResolvedContentRef } from '@/lib/references';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||||
|
import { Icon, type IconName } from '@gitbook/icons';
|
||||||
|
import { CONTAINER_STYLE } from '../layout';
|
||||||
|
import { Link, linkStyles } from '../primitives';
|
||||||
|
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side component to enable closing the banner
|
||||||
|
*/
|
||||||
|
export function AnnouncementBanner(props: {
|
||||||
|
announcement: CustomizationAnnouncement;
|
||||||
|
contentRef: ResolvedContentRef | null;
|
||||||
|
}) {
|
||||||
|
const { announcement, contentRef } = props;
|
||||||
|
|
||||||
|
const hasLink = announcement.link && contentRef?.href;
|
||||||
|
const closeable = announcement.style !== 'danger';
|
||||||
|
|
||||||
|
const Tag = hasLink ? Link : 'div';
|
||||||
|
const style = BANNER_STYLES[announcement.style];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
|
||||||
|
<div className="scroll-nojump">
|
||||||
|
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||||
|
<Tag
|
||||||
|
href={contentRef?.href ?? ''}
|
||||||
|
className={tcls(
|
||||||
|
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
|
||||||
|
style.container,
|
||||||
|
closeable && 'pr-12',
|
||||||
|
hasLink && style.hover
|
||||||
|
)}
|
||||||
|
insights={
|
||||||
|
announcement.link
|
||||||
|
? {
|
||||||
|
type: 'link_click',
|
||||||
|
link: {
|
||||||
|
target: announcement.link.to,
|
||||||
|
position: SiteInsightsLinkPosition.Announcement,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={style.icon as IconName}
|
||||||
|
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{announcement.message}
|
||||||
|
{hasLink ? (
|
||||||
|
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
|
||||||
|
{contentRef?.icon ? (
|
||||||
|
<span className="mr-1 ml-2 *:inline">
|
||||||
|
{contentRef?.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{announcement.link?.title && (
|
||||||
|
<span className="mr-1">{announcement.link?.title}</span>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
announcement.link?.to.kind === 'url'
|
||||||
|
? 'arrow-up-right'
|
||||||
|
: 'chevron-right'
|
||||||
|
}
|
||||||
|
className={tcls('mb-0.5 inline size-3')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
|
{closeable ? (
|
||||||
|
<button
|
||||||
|
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
|
||||||
|
type="button"
|
||||||
|
onClick={dismissAnnouncement}
|
||||||
|
>
|
||||||
|
<Icon icon="close" className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the announcement banner and store the dismissal state in local storage.
|
||||||
|
* @see AnnouncementScript
|
||||||
|
*/
|
||||||
|
function dismissAnnouncement() {
|
||||||
|
storage.setItem(ANNOUNCEMENT_STORAGE_KEY, {
|
||||||
|
visible: false,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.documentElement.classList.add(ANNOUNCEMENT_CSS_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BANNER_STYLES = {
|
||||||
|
info: {
|
||||||
|
container: 'bg-info ring-info-subtle',
|
||||||
|
hover: 'hover:bg-info-hover active:bg-info-active',
|
||||||
|
icon: 'circle-info',
|
||||||
|
iconColor: 'text-info-subtle',
|
||||||
|
close: 'hover:bg-tint-base hover:ring-info-subtle',
|
||||||
|
link: '',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
container: 'bg-warning decoration-warning/6 ring-warning-subtle',
|
||||||
|
hover: 'hover:bg-warning-hover',
|
||||||
|
icon: 'circle-exclamation',
|
||||||
|
iconColor: 'text-warning-subtle',
|
||||||
|
close: 'hover:bg-tint-base hover:ring-warning-subtle',
|
||||||
|
link: 'links-default:text-warning links-default:hover:text-warning-strong links-default:decoration-warning/6 links-accent:decoration-warning',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
container: 'bg-danger decoration-danger/6 ring-danger-subtle',
|
||||||
|
hover: 'hover:bg-danger-hover',
|
||||||
|
icon: 'triangle-exclamation',
|
||||||
|
iconColor: 'text-danger-subtle',
|
||||||
|
close: 'hover:bg-tint-base hover:ring-danger-subtle',
|
||||||
|
link: 'links-default:text-danger links-default:hover:text-danger-strong links-default:decoration-danger/6 links-accent:decoration-danger',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
container: 'bg-success decoration-success/6 ring-success-subtle',
|
||||||
|
hover: 'hover:bg-success-hover',
|
||||||
|
icon: 'circle-check',
|
||||||
|
iconColor: 'text-success-subtle',
|
||||||
|
close: 'hover:bg-tint-base hover:ring-success-subtle',
|
||||||
|
link: 'links-default:text-success links-default:hover:text-success-strong links-default:decoration-success/6 links-accent:decoration-success',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
ANNOUNCEMENT_CSS_CLASS,
|
||||||
|
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||||
|
ANNOUNCEMENT_STORAGE_KEY,
|
||||||
|
} from './constants';
|
||||||
|
import { checkStorageForDismissedScript } from './script';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject a script to read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element as early as possible.
|
||||||
|
* Bypasses react state to prevent flickering.
|
||||||
|
*/
|
||||||
|
export function AnnouncementDismissedScript() {
|
||||||
|
const scriptArgs = JSON.stringify([
|
||||||
|
ANNOUNCEMENT_STORAGE_KEY,
|
||||||
|
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||||
|
ANNOUNCEMENT_CSS_CLASS,
|
||||||
|
]).slice(1, -1);
|
||||||
|
|
||||||
|
// makes sure the banner dismissed state is kept when navigating between sites
|
||||||
|
React.useEffect(() => {
|
||||||
|
checkStorageForDismissedScript(
|
||||||
|
ANNOUNCEMENT_STORAGE_KEY,
|
||||||
|
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||||
|
ANNOUNCEMENT_CSS_CLASS
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
suppressHydrationWarning
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(${checkStorageForDismissedScript.toString()})(${scriptArgs})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* The local storage key for the announcement banner.
|
||||||
|
*/
|
||||||
|
export const ANNOUNCEMENT_STORAGE_KEY = '@gitbook/announcement';
|
||||||
|
/**
|
||||||
|
* The CSS class to hide the announcement banner. Applies to the <html> element.
|
||||||
|
*/
|
||||||
|
export const ANNOUNCEMENT_CSS_CLASS = 'announcement-hidden';
|
||||||
|
/**
|
||||||
|
* The number of days until the announcement banner resets.
|
||||||
|
*/
|
||||||
|
export const ANNOUNCEMENT_DAYS_TILL_RESET = 7;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './Announcement';
|
||||||
|
export * from './AnnouncementDismissedScript';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element.
|
||||||
|
*
|
||||||
|
* NOTE: this script is stringified and run in the browser, so it must be self-contained and have syntax supported in all browsers.
|
||||||
|
*/
|
||||||
|
export function checkStorageForDismissedScript(
|
||||||
|
storageKey: string,
|
||||||
|
daysTillReset: number,
|
||||||
|
cssClass: string
|
||||||
|
) {
|
||||||
|
let showBanner = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const announcementStateStr = window.localStorage.getItem(storageKey);
|
||||||
|
const announcementState = announcementStateStr
|
||||||
|
? JSON.parse(announcementStateStr)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (announcementState && !announcementState.visible) {
|
||||||
|
const dismissedAt = announcementState.at;
|
||||||
|
const nowTime = new Date().getTime();
|
||||||
|
|
||||||
|
// Check if enough days have passed since dismissal
|
||||||
|
const daysSinceDismissal = Math.floor((nowTime - dismissedAt) / (1000 * 60 * 60 * 24));
|
||||||
|
if (daysSinceDismissal < daysTillReset) {
|
||||||
|
showBanner = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!showBanner) {
|
||||||
|
document.documentElement.classList.add(cssClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateState(true);
|
onUpdateState(true);
|
||||||
}}
|
}}
|
||||||
>
|
label={tString(language, 'cookies_accept')}
|
||||||
{t(language, 'cookies_accept')}
|
/>
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateState(false);
|
onUpdateState(false);
|
||||||
}}
|
}}
|
||||||
>
|
label={tString(language, 'cookies_reject')}
|
||||||
{t(language, 'cookies_reject')}
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
|
|||||||
import { List } from './List';
|
import { List } from './List';
|
||||||
import { ListItem } from './ListItem';
|
import { ListItem } from './ListItem';
|
||||||
import { BlockMath } from './Math';
|
import { BlockMath } from './Math';
|
||||||
import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
|
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
|
||||||
import { Paragraph } from './Paragraph';
|
import { Paragraph } from './Paragraph';
|
||||||
import { Quote } from './Quote';
|
import { Quote } from './Quote';
|
||||||
import { ReusableContent } from './ReusableContent';
|
import { ReusableContent } from './ReusableContent';
|
||||||
@@ -85,6 +85,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
|||||||
return <OpenAPIOperation {...props} block={block} />;
|
return <OpenAPIOperation {...props} block={block} />;
|
||||||
case 'openapi-schemas':
|
case 'openapi-schemas':
|
||||||
return <OpenAPISchemas {...props} block={block} />;
|
return <OpenAPISchemas {...props} block={block} />;
|
||||||
|
case 'openapi-webhook':
|
||||||
|
return <OpenAPIWebhook {...props} block={block} />;
|
||||||
case 'embed':
|
case 'embed':
|
||||||
return <Embed {...props} block={block} />;
|
return <Embed {...props} block={block} />;
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
@@ -159,6 +161,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
|||||||
case 'swagger':
|
case 'swagger':
|
||||||
case 'openapi-operation':
|
case 'openapi-operation':
|
||||||
case 'openapi-schemas':
|
case 'openapi-schemas':
|
||||||
|
case 'openapi-webhook':
|
||||||
case 'math':
|
case 'math':
|
||||||
case 'divider':
|
case 'divider':
|
||||||
case 'content-ref':
|
case 'content-ref':
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
|||||||
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;
|
const { nodes, blockStyle, isOffscreen: defaultIsOffscreen = false, ...contextProps } = props;
|
||||||
|
|
||||||
let isOffscreen = defaultIsOffscreen;
|
let isOffscreen = defaultIsOffscreen;
|
||||||
return nodes.map((node) => {
|
return nodes.map((node, index) => {
|
||||||
isOffscreen =
|
isOffscreen =
|
||||||
isOffscreen ||
|
isOffscreen ||
|
||||||
isBlockOffscreen({
|
isBlockOffscreen({
|
||||||
@@ -65,7 +65,7 @@ export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Block
|
<Block
|
||||||
key={node.key}
|
key={node.key || `${node.type}-${index}`}
|
||||||
block={node}
|
block={node}
|
||||||
style={[
|
style={[
|
||||||
'mx-auto w-full decoration-primary/6',
|
'mx-auto w-full decoration-primary/6',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
const [isInViewport, setIsInViewport] = useState(false);
|
const [isInViewport, setIsInViewport] = useState(false);
|
||||||
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
||||||
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
||||||
|
const [highlighting, setHighlighting] = useState(false);
|
||||||
|
|
||||||
// Preload the highlighter when the block is mounted.
|
// Preload the highlighter when the block is mounted.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,6 +78,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
setHighlighting(true);
|
||||||
import('./highlight').then(({ highlight }) => {
|
import('./highlight').then(({ highlight }) => {
|
||||||
highlight(block, inlines).then((lines) => {
|
highlight(block, inlines).then((lines) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -84,6 +86,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLines(lines);
|
setLines(lines);
|
||||||
|
setHighlighting(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,6 +101,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
}, [isInViewport, block, inlines]);
|
}, [isInViewport, block, inlines]);
|
||||||
|
|
||||||
return (
|
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 {
|
.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)];
|
@apply before:text-tint before:content-[counter(line)];
|
||||||
|
|
||||||
.highlight-line.highlighted > & {
|
.highlight-line.highlighted > & {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import './CodeBlockRenderer.css';
|
|||||||
|
|
||||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||||
lines: HighlightLine[];
|
lines: HighlightLine[];
|
||||||
|
'aria-busy'?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
props: CodeBlockRendererProps,
|
props: CodeBlockRendererProps,
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
const { block, style, lines } = props;
|
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
||||||
|
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
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;
|
const title = block.data.title;
|
||||||
|
|
||||||
return (
|
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]">
|
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
||||||
{title ? (
|
{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}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -45,15 +50,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
/>
|
/>
|
||||||
<pre
|
<pre
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]',
|
'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',
|
'rounded-md straight-corners:rounded-sm shadow-sm',
|
||||||
title && 'rounded-ss-none'
|
title && 'rounded-ss-none'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<code
|
<code
|
||||||
id={id}
|
id={id}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line]',
|
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap',
|
||||||
withWrap && 'whitespace-pre-wrap'
|
withWrap && 'whitespace-pre-wrap'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,31 +1,67 @@
|
|||||||
:root {
|
:root {
|
||||||
--shiki-color-text: theme("colors.tint.11");
|
--shiki-color-text: theme("colors.tint.11");
|
||||||
--shiki-token-constant: #0a6355;
|
--shiki-token-punctuation: theme("colors.tint.11");
|
||||||
--shiki-token-string: #8b6d32;
|
--shiki-token-comment: theme("colors.neutral.9/.7");
|
||||||
--shiki-token-comment: theme("colors.teal.700/.64");
|
--shiki-token-link: theme("colors.primary.10");
|
||||||
--shiki-token-keyword: theme("colors.pomegranate.600");
|
|
||||||
--shiki-token-parameter: #0a3069;
|
--shiki-token-constant: theme("colors.warning.10");
|
||||||
--shiki-token-function: #8250df;
|
--shiki-token-string: theme("colors.warning.10");
|
||||||
--shiki-token-string-expression: #6a4906;
|
--shiki-token-string-expression: theme("colors.success.10");
|
||||||
--shiki-token-punctuation: theme("colors.pomegranate.700/.92");
|
--shiki-token-keyword: theme("colors.danger.10");
|
||||||
--shiki-token-link: theme("colors.tint.12");
|
--shiki-token-parameter: theme("colors.warning.10");
|
||||||
--shiki-token-inserted: #22863a;
|
--shiki-token-function: theme("colors.primary.10");
|
||||||
--shiki-token-deleted: #b31d28;
|
|
||||||
--shiki-token-changed: #8250df;
|
--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.warning.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 {
|
html.dark {
|
||||||
--shiki-color-text: theme("colors.tint.11");
|
/* Override select colors to have more contrast */
|
||||||
--shiki-token-constant: #d19a66;
|
--shiki-token-comment: theme("colors.neutral.9");
|
||||||
--shiki-token-string: theme("colors.pomegranate.300");
|
|
||||||
--shiki-token-comment: theme("colors.teal.300/.64");
|
--shiki-token-constant: theme("colors.warning.11");
|
||||||
--shiki-token-keyword: theme("colors.pomegranate.400");
|
--shiki-token-string: theme("colors.warning.11");
|
||||||
--shiki-token-parameter: theme("colors.yellow.500");
|
--shiki-token-string-expression: theme("colors.success.11");
|
||||||
--shiki-token-function: #56b6c2;
|
--shiki-token-keyword: theme("colors.danger.11");
|
||||||
--shiki-token-string-expression: theme("colors.tint.11");
|
--shiki-token-parameter: theme("colors.warning.11");
|
||||||
--shiki-token-punctuation: #acc6ee;
|
--shiki-token-function: theme("colors.primary.11");
|
||||||
--shiki-token-link: theme("colors.pomegranate.400");
|
}
|
||||||
--shiki-token-inserted: #85e89d;
|
|
||||||
--shiki-token-deleted: #fdaeb7;
|
.code-monochrome {
|
||||||
--shiki-token-changed: #56b6c2;
|
--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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card } from '@/components/primitives';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { getDataOrNull } from '@v2/lib/data';
|
import { getDataOrNull } from '@v2/lib/data';
|
||||||
|
import { Image } from '../utils';
|
||||||
import type { BlockProps } from './Block';
|
import type { BlockProps } from './Block';
|
||||||
import { Caption } from './Caption';
|
import { Caption } from './Caption';
|
||||||
import { IntegrationBlock } from './Integration';
|
import { IntegrationBlock } from './Integration';
|
||||||
@@ -52,7 +53,14 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
|||||||
<Card
|
<Card
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
embed.icon ? (
|
embed.icon ? (
|
||||||
<img src={embed.icon} className={tcls('w-5', 'h-5')} alt="Logo" />
|
<Image
|
||||||
|
src={embed.icon}
|
||||||
|
className={tcls('w-5', 'h-5')}
|
||||||
|
alt="Logo"
|
||||||
|
sources={{ light: { src: embed.icon } }}
|
||||||
|
sizes={[{ width: 20 }]}
|
||||||
|
resize={context.contentContext.imageResizer}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
href={block.data.url}
|
href={block.data.url}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { getBlockTextStyle } from './spacing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hash icon which adds the block or active block item's ID in the URL hash.
|
||||||
|
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
|
||||||
|
*/
|
||||||
|
export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
|
||||||
|
|
||||||
|
export function HashLinkButton(props: {
|
||||||
|
id: string;
|
||||||
|
block: DocumentBlockTabs | DocumentBlockHeading;
|
||||||
|
label?: string;
|
||||||
|
className?: ClassValue;
|
||||||
|
iconClassName?: ClassValue;
|
||||||
|
}) {
|
||||||
|
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
|
||||||
|
const textStyle = getBlockTextStyle(block);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'relative',
|
||||||
|
'hash',
|
||||||
|
'grid',
|
||||||
|
'grid-area-1-1',
|
||||||
|
'h-[1em]',
|
||||||
|
'border-0',
|
||||||
|
'opacity-0',
|
||||||
|
'group-hover/hash:opacity-[0]',
|
||||||
|
'group-focus/hash:opacity-[0]',
|
||||||
|
'md:group-hover/hash:md:opacity-[1]',
|
||||||
|
'md:group-focus/hash:md:opacity-[1]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`#${id}`}
|
||||||
|
aria-label={label}
|
||||||
|
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="hashtag"
|
||||||
|
className={tcls(
|
||||||
|
'size-3',
|
||||||
|
'self-center',
|
||||||
|
'transition-colors',
|
||||||
|
'text-transparent',
|
||||||
|
'group-hover/hash:text-tint-subtle',
|
||||||
|
'contrast-more:group-hover/hash:text-tint-strong',
|
||||||
|
iconClassName
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { DocumentBlockHeading } from '@gitbook/api';
|
import type { DocumentBlockHeading } from '@gitbook/api';
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import type { BlockProps } from './Block';
|
import type { BlockProps } from './Block';
|
||||||
|
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
|
||||||
import { Inlines } from './Inlines';
|
import { Inlines } from './Inlines';
|
||||||
import { getBlockTextStyle } from './spacing';
|
import { getBlockTextStyle } from './spacing';
|
||||||
|
|
||||||
@@ -20,45 +20,23 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
|||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
id={id}
|
id={id}
|
||||||
className={tcls(textStyle.textSize, 'heading', 'group', 'relative', 'grid', style)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'hash',
|
textStyle.textSize,
|
||||||
|
'heading',
|
||||||
'grid',
|
'grid',
|
||||||
'grid-area-1-1',
|
'scroll-m-12',
|
||||||
'relative',
|
hashLinkButtonWrapperStyles,
|
||||||
'-ml-6',
|
style
|
||||||
'w-7',
|
|
||||||
'border-0',
|
|
||||||
'opacity-0',
|
|
||||||
'group-hover:opacity-[0]',
|
|
||||||
'group-focus:opacity-[0]',
|
|
||||||
'md:group-hover:md:opacity-[1]',
|
|
||||||
'md:group-focus:md:opacity-[1]',
|
|
||||||
textStyle.marginTop
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<a
|
<HashLinkButton
|
||||||
href={`#${id}`}
|
id={id}
|
||||||
aria-label="Direct link to heading"
|
block={block}
|
||||||
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
|
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
|
||||||
>
|
iconClassName={tcls('size-4')}
|
||||||
<Icon
|
label="Direct link to heading"
|
||||||
icon="hashtag"
|
|
||||||
className={tcls(
|
|
||||||
'w-3.5',
|
|
||||||
'h-[1em]',
|
|
||||||
'mt-0.5',
|
|
||||||
'transition-colors',
|
|
||||||
'text-transparent',
|
|
||||||
'group-hover:text-tint-subtle',
|
|
||||||
'contrast-more:group-hover:text-tint-strong',
|
|
||||||
'lg:w-4'
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'grid-area-1-1',
|
'grid-area-1-1',
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const HINT_STYLES: {
|
|||||||
'[&_.can-override-text]:text-neutral-strong',
|
'[&_.can-override-text]:text-neutral-strong',
|
||||||
],
|
],
|
||||||
container:
|
container:
|
||||||
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2',
|
'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
|
||||||
containerWithHeader: 'border-info-solid bg-info-subtle',
|
containerWithHeader: 'border-info-solid bg-info-subtle',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
DocumentInline,
|
DocumentInline,
|
||||||
DocumentInlineAnnotation,
|
DocumentInlineAnnotation,
|
||||||
|
DocumentInlineButton,
|
||||||
DocumentInlineEmoji,
|
DocumentInlineEmoji,
|
||||||
DocumentInlineImage,
|
DocumentInlineImage,
|
||||||
DocumentInlineLink,
|
DocumentInlineLink,
|
||||||
@@ -13,6 +14,7 @@ import assertNever from 'assert-never';
|
|||||||
import { Annotation } from './Annotation/Annotation';
|
import { Annotation } from './Annotation/Annotation';
|
||||||
import type { DocumentContextProps } from './DocumentView';
|
import type { DocumentContextProps } from './DocumentView';
|
||||||
import { Emoji } from './Emoji';
|
import { Emoji } from './Emoji';
|
||||||
|
import { InlineButton } from './InlineButton';
|
||||||
import { InlineImage } from './InlineImage';
|
import { InlineImage } from './InlineImage';
|
||||||
import { InlineLink } from './InlineLink';
|
import { InlineLink } from './InlineLink';
|
||||||
import { InlineMath } from './Math';
|
import { InlineMath } from './Math';
|
||||||
@@ -44,7 +46,8 @@ export function Inline<
|
|||||||
| DocumentInlineEmoji
|
| DocumentInlineEmoji
|
||||||
| DocumentInlineLink
|
| DocumentInlineLink
|
||||||
| DocumentInlineMath
|
| DocumentInlineMath
|
||||||
| DocumentInlineMention,
|
| DocumentInlineMention
|
||||||
|
| DocumentInlineButton,
|
||||||
>(props: InlineProps<T>) {
|
>(props: InlineProps<T>) {
|
||||||
const { inline, ...contextProps } = props;
|
const { inline, ...contextProps } = props;
|
||||||
|
|
||||||
@@ -61,6 +64,8 @@ export function Inline<
|
|||||||
return <Mention {...contextProps} inline={inline} />;
|
return <Mention {...contextProps} inline={inline} />;
|
||||||
case 'inline-image':
|
case 'inline-image':
|
||||||
return <InlineImage {...contextProps} inline={inline} />;
|
return <InlineImage {...contextProps} inline={inline} />;
|
||||||
|
case 'button':
|
||||||
|
return <InlineButton {...contextProps} inline={inline} />;
|
||||||
default:
|
default:
|
||||||
assertNever(inline);
|
assertNever(inline);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user