mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
151 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 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI tabs indicator overflow
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix XML in code sample
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Disallow crawling by web-robots of search/ask URLs
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Safe parse OpenAPI JSON schema
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix multiple request examples selector not showing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support site announcement banner
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix a crash when a page contains a block of an integration that is no longer installed
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix flash when loading sites with dark mode as default theme
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@gitbook/react-openapi": patch
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix spec properties rendering and missing keys
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Replace $ref with $reference in json-decycle
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Trim the search query to avoid showing a loading state when typing
|
||||
@@ -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/`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ inputs:
|
||||
deploy:
|
||||
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||
required: true
|
||||
commitTag:
|
||||
description: 'Commit branch to associate with the deployment'
|
||||
required: true
|
||||
commitMessage:
|
||||
description: 'Commit message to associate with the deployment'
|
||||
required: true
|
||||
outputs:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
@@ -49,10 +55,13 @@ runs:
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build worker
|
||||
run: bun run turbo build:v2:cloudflare
|
||||
env:
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
shell: bash
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
@@ -61,9 +70,9 @@ runs:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '3.112.0'
|
||||
wranglerVersion: '4.10.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
|
||||
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -54,6 +54,7 @@ runs:
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build Project Artifacts
|
||||
@@ -62,6 +63,7 @@ runs:
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
GITBOOK_RUNTIME: vercel
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
shell: bash
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
deploy-v1-cloudflare:
|
||||
name: Deploy v1 to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
permissions:
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
run: bun run turbo gitbook#build:cloudflare
|
||||
env:
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3.14.0
|
||||
@@ -56,7 +57,7 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2v-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
@@ -77,11 +78,11 @@ jobs:
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (preview)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
environment:
|
||||
name: 2c-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -95,9 +96,19 @@ jobs:
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
opItem: op://gitbook-open/2c-preview
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Extract Worker ID
|
||||
id: extract-worker-id
|
||||
if: ${{ !steps.deploy.outputs.deployment-url }}
|
||||
run: |
|
||||
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
|
||||
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
|
||||
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
|
||||
comment-deployments:
|
||||
runs-on: ubuntu-latest
|
||||
name: Comment Deployments (preview)
|
||||
@@ -123,14 +134,14 @@ jobs:
|
||||
body: |
|
||||
Summary of the deployments:
|
||||
|
||||
### Version 1 (production)
|
||||
### Version 1
|
||||
|
||||
| Version | URL | Status |
|
||||
| --- | --- | --- |
|
||||
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||
|
||||
### Version 2 (experimental)
|
||||
### Version 2
|
||||
|
||||
| Version | URL | Status |
|
||||
| --- | --- | --- |
|
||||
@@ -139,10 +150,10 @@ jobs:
|
||||
|
||||
### Test content
|
||||
|
||||
| Site | v1 | v2 |
|
||||
| --- | --- | --- |
|
||||
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) |
|
||||
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||
| Site | `v1` | `2v` | `2c` |
|
||||
| --- | --- | --- | --- |
|
||||
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/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) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||
edit-mode: replace
|
||||
visual-testing-v1:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -184,11 +195,32 @@ jobs:
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-vercel'
|
||||
visual-testing-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
||||
visual-testing-customers-v1:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v1
|
||||
needs: deploy-v1-cloudflare
|
||||
timeout-minutes: 6
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -208,7 +240,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2
|
||||
needs: deploy-v2-vercel
|
||||
timeout-minutes: 6
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -225,6 +257,27 @@ jobs:
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e-customers
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
pagespeed-testing-v1:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing v1
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Deploy staging
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
opItem: op://gitbook-open/2c-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
commitTag: main
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Deploy staging
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
@@ -48,6 +48,8 @@ jobs:
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
opItem: op://gitbook-open/2c-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
commitTag: main
|
||||
commitMessage: ${{ github.sha }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
Vendored
+1
@@ -9,6 +9,7 @@
|
||||
],
|
||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center">GitBook</h1>
|
||||
|
||||
<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 align="center">
|
||||
@@ -59,15 +59,15 @@ bun install
|
||||
4. Start your local development server.
|
||||
|
||||
```
|
||||
bun dev
|
||||
bun dev:v2
|
||||
```
|
||||
|
||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||
|
||||
examples:
|
||||
|
||||
- http://localhost:3000/docs.gitbook.com
|
||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
||||
- http://localhost:3000/url/gitbook.com/docs
|
||||
- http://localhost:3000/url/open-source.gitbook.io/midjourney
|
||||
|
||||
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
||||
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"**/.wrangler/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json"
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/cache-do/worker-configuration.d.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
+5
-5
@@ -4,15 +4,15 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"turbo": "^2.4.4",
|
||||
"turbo": "^2.5.0",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.5",
|
||||
"packageManager": "bun@1.2.11",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"@gitbook/api": "0.102.0"
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.112.0"
|
||||
"wrangler": "^4.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
"generate": "wrangler types",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
||||
"types": ["./worker-configuration.d.ts"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"generate": {
|
||||
"outputs": ["worker-configuration.d.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
# @gitbook/cache-tags
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 77397ca: Fix version of @gitbook/api referenced in package.json
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -60,7 +60,7 @@ export function getCacheTag(
|
||||
| {
|
||||
tag: 'computed-document';
|
||||
space: string;
|
||||
integration: string;
|
||||
sourceType: string;
|
||||
}
|
||||
/**
|
||||
* All data related to the URL of a content
|
||||
@@ -84,6 +84,14 @@ export function getCacheTag(
|
||||
organization: string;
|
||||
openAPISpec: string;
|
||||
}
|
||||
/**
|
||||
* All data related to a translation
|
||||
*/
|
||||
| {
|
||||
tag: 'translation';
|
||||
organization: string;
|
||||
translationSettings: string;
|
||||
}
|
||||
): string {
|
||||
switch (spec.tag) {
|
||||
case 'user':
|
||||
@@ -99,13 +107,15 @@ export function getCacheTag(
|
||||
case 'document':
|
||||
return `space:${spec.space}:document:${spec.document}`;
|
||||
case 'computed-document':
|
||||
return `space:${spec.space}:computed-document:${spec.integration}`;
|
||||
return `space:${spec.space}:computed-document:${spec.sourceType}`;
|
||||
case 'site':
|
||||
return `site:${spec.site}`;
|
||||
case 'integration':
|
||||
return `integration:${spec.integration}`;
|
||||
case 'openapi':
|
||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||
case 'translation':
|
||||
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
||||
default:
|
||||
assertNever(spec);
|
||||
}
|
||||
@@ -157,6 +167,15 @@ export function getComputedContentSourceCacheTags(
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'translation-language':
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'translation',
|
||||
organization: inContext.organizationId,
|
||||
translationSettings: dependency.ref.translationSettings,
|
||||
})
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Do not throw for unknown dependency types
|
||||
// as it might mean we are lacking behind the API version
|
||||
@@ -169,18 +188,22 @@ export function getComputedContentSourceCacheTags(
|
||||
getCacheTag({
|
||||
tag: 'computed-document',
|
||||
space: inContext.spaceId,
|
||||
integration: source.integration,
|
||||
sourceType: source.type,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// We invalidate the computed content when a new version of the integration is deployed.
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
integration: source.integration,
|
||||
})
|
||||
);
|
||||
|
||||
if (source.type.startsWith('integration:')) {
|
||||
const integration = source.type.split(':')[1];
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
integration,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.3",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
|
||||
@@ -214,13 +214,31 @@ export function colorScale(
|
||||
const targetL =
|
||||
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.
|
||||
result.push(hex);
|
||||
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 = {
|
||||
L: targetL, // Blend lightness
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
# cloudflare
|
||||
.open-next
|
||||
.wrangler
|
||||
|
||||
# Symbolic links
|
||||
public
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -7,8 +7,6 @@ const nextConfig = {
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
|
||||
// This is needed to use 'use cache'
|
||||
useCache: true,
|
||||
|
||||
// Content is fully static, we can cache it in the session memory cache for a long time
|
||||
@@ -33,7 +31,9 @@ const nextConfig = {
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||
|
||||
// Next.js envs
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
@@ -53,6 +53,24 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/~gitbook/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: '*',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
||||
import d1TagCache from '@opennextjs/cloudflare/d1-tag-cache';
|
||||
import kvIncrementalCache from '@opennextjs/cloudflare/kv-cache';
|
||||
import memoryQueue from '@opennextjs/cloudflare/memory-queue';
|
||||
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||
import {
|
||||
softTagFilter,
|
||||
withFilter,
|
||||
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
incrementalCache: kvIncrementalCache,
|
||||
queue: memoryQueue,
|
||||
tagCache: d1TagCache,
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
tagCache: withFilter({
|
||||
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,23 +1,23 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"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-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",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"p-memoize": "^7.1.1"
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gitbook": "*",
|
||||
"@opennextjs/cloudflare": "^0.5.10",
|
||||
"@types/rison": "^0.0.9",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8"
|
||||
@@ -28,8 +28,8 @@
|
||||
"build": "next build",
|
||||
"build:v2": "next build",
|
||||
"start": "next start",
|
||||
"build:v2:cloudflare": "opennextjs-cloudflare",
|
||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
||||
"build:v2:cloudflare": "opennextjs-cloudflare build",
|
||||
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||
"unit": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
|
||||
-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,
|
||||
generateSitePageViewport,
|
||||
} from '@/components/SitePage';
|
||||
import { getCacheTag } from '@gitbook/cache-tags';
|
||||
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
||||
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
@@ -15,19 +14,10 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
'use cache';
|
||||
|
||||
const params = await props.params;
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: context.site.id,
|
||||
})
|
||||
);
|
||||
|
||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import { getCacheTag } from '@gitbook/cache-tags';
|
||||
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
|
||||
interface SiteStaticLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -17,17 +15,8 @@ export default async function SiteStaticLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
'use cache';
|
||||
|
||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: context.site.id,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
<SiteLayout
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ export async function GET(
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
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 type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
|
||||
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||
import type { SiteAPIToken } from '@gitbook/api';
|
||||
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { forbidden } from 'next/navigation';
|
||||
import rison from 'rison';
|
||||
@@ -98,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
|
||||
/**
|
||||
* Get the decoded site data from the params.
|
||||
*/
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||
const decoded = decodeURIComponent(params.siteData);
|
||||
return rison.decode(decoded);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,43 @@ import type {
|
||||
SiteStructure,
|
||||
Space,
|
||||
} 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 { GITBOOK_URL } from './env';
|
||||
import { type ImageResizer, createImageResizer } from './images';
|
||||
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.
|
||||
*/
|
||||
@@ -105,7 +136,7 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
|
||||
*/
|
||||
export function getBaseContext(input: {
|
||||
siteURL: URL | string;
|
||||
siteURLData: PublishedSiteContent;
|
||||
siteURLData: SiteURLData;
|
||||
urlMode: 'url' | 'url-host';
|
||||
}) {
|
||||
const { urlMode, siteURLData } = input;
|
||||
@@ -139,7 +170,7 @@ export function getBaseContext(input: {
|
||||
}
|
||||
|
||||
const imageResizer = createImageResizer({
|
||||
host: siteURL.host,
|
||||
imagesContextId: siteURLData.imagesContextId,
|
||||
// To ensure image resizing work for proxied sites,
|
||||
// we serve images from the root of the site.
|
||||
linker: linker,
|
||||
@@ -157,7 +188,7 @@ export function getBaseContext(input: {
|
||||
*/
|
||||
export async function fetchSiteContextByURLLookup(
|
||||
baseContext: GitBookBaseContext,
|
||||
data: PublishedSiteContent
|
||||
data: SiteURLData
|
||||
): Promise<GitBookSiteContext> {
|
||||
return await fetchSiteContextByIds(baseContext, {
|
||||
organization: data.organization,
|
||||
@@ -212,19 +243,45 @@ export async function fetchSiteContextByIds(
|
||||
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||
: null;
|
||||
|
||||
const siteSpace = (
|
||||
siteStructure.type === 'siteSpaces' && siteStructure.structure
|
||||
? siteStructure.structure
|
||||
: sections?.current.siteSpaces
|
||||
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||
if (!siteSpace) {
|
||||
throw new Error('Site space not found');
|
||||
}
|
||||
// Parse the current siteSpace and siteSpaces based on the site structure type.
|
||||
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
|
||||
if (siteStructure.type === 'siteSpaces') {
|
||||
const siteSpaces = siteStructure.structure;
|
||||
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||
|
||||
const siteSpaces =
|
||||
siteStructure.type === 'siteSpaces'
|
||||
? siteStructure.structure
|
||||
: (sections?.current.siteSpaces ?? []);
|
||||
if (!siteSpace) {
|
||||
throw new Error(
|
||||
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
|
||||
);
|
||||
}
|
||||
|
||||
return { siteSpaces, siteSpace };
|
||||
}
|
||||
|
||||
if (siteStructure.type === 'sections') {
|
||||
assert(
|
||||
sections,
|
||||
`cannot find site space "${ids.siteSpace}" because parsed sections are missing siteStructure.type="sections" siteSection="${ids.siteSection}"`
|
||||
);
|
||||
|
||||
const currentSection = sections.current;
|
||||
const siteSpaces = currentSection.siteSpaces;
|
||||
const siteSpace = currentSection.siteSpaces.find(
|
||||
(siteSpace) => siteSpace.id === ids.siteSpace
|
||||
);
|
||||
|
||||
if (!siteSpace) {
|
||||
throw new Error(
|
||||
`Site space "${ids.siteSpace}" not found in structure type="sections" currentSection="${currentSection.id}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { siteSpaces, siteSpace };
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
assertNever(siteStructure, `cannot handle site structure of type ${siteStructure.type}`);
|
||||
})();
|
||||
|
||||
const customization = (() => {
|
||||
if (ids.siteSpace) {
|
||||
@@ -277,7 +334,7 @@ export async function fetchSpaceContextByIds(
|
||||
})
|
||||
),
|
||||
ids.changeRequest
|
||||
? throwIfDataError(
|
||||
? getDataOrNull(
|
||||
dataFetcher.getChangeRequest({
|
||||
spaceId: ids.space,
|
||||
changeRequestId: ids.changeRequest,
|
||||
@@ -286,17 +343,30 @@ export async function fetchSpaceContextByIds(
|
||||
: 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({
|
||||
spaceId: ids.space,
|
||||
revisionId,
|
||||
// We only care about the Git metadata when the Git sync is enabled,
|
||||
// otherwise we can optimize performance by not fetching it
|
||||
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 {
|
||||
...baseContext,
|
||||
@@ -337,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
|
||||
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
||||
const section = parseCurrentSection(structure, siteSectionId);
|
||||
assert(section, 'A section must be defined when there are multiple sections');
|
||||
assert(section, `couldn't find section "${siteSectionId}" in site structure`);
|
||||
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import { getCloudflareContext as getCloudflareContextOpenNext } from '@opennextjs/cloudflare';
|
||||
import { GITBOOK_RUNTIME } from '../env';
|
||||
|
||||
/**
|
||||
* Return the Cloudflare context or null when not running in Cloudflare.
|
||||
*/
|
||||
export function getCloudflareContext() {
|
||||
if (GITBOOK_RUNTIME !== 'cloudflare') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getCloudflareContextOpenNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an object representing the current request.
|
||||
*/
|
||||
export function getCloudflareRequestGlobal() {
|
||||
const context = getCloudflareContext();
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return context.cf;
|
||||
}
|
||||
@@ -98,6 +98,10 @@ export async function wrapDataFetcherError<T>(
|
||||
*/
|
||||
export function getExposableError(error: Error): DataFetcherErrorData {
|
||||
if (error instanceof GitBookAPIError) {
|
||||
if (error.code >= 500) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.errorMessage,
|
||||
@@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
|
||||
}
|
||||
|
||||
if (error instanceof DataFetcherError) {
|
||||
if (error.code >= 500) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
|
||||
@@ -4,3 +4,5 @@ export * from './pages';
|
||||
export * from './urls';
|
||||
export * from './errors';
|
||||
export * from './lookup';
|
||||
export * from './proxy';
|
||||
export * from './visitor';
|
||||
|
||||
@@ -1,48 +1,102 @@
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
||||
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
interface LookupPublishedContentByUrlInput {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
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>> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const api = await apiClient({ apiToken: input.apiToken });
|
||||
|
||||
const callResult = await trace(
|
||||
{
|
||||
operation: 'getPublishedContentByURL',
|
||||
name: alternative.url,
|
||||
},
|
||||
() =>
|
||||
tryCatch(
|
||||
api.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url: alternative.url,
|
||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||
redirectOnError: input.redirectOnError,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const callResult = await input.fetchLookupAPIResult({
|
||||
url: alternative.url,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (callResult.error) {
|
||||
if (alternative.primary) {
|
||||
@@ -100,6 +154,7 @@ export async function getPublishedContentByURL(input: {
|
||||
|
||||
const siteResult: PublishedSiteContentLookup = {
|
||||
...data,
|
||||
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
|
||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||
...(changeRequest ? { changeRequest } : {}),
|
||||
|
||||
@@ -1,39 +1,52 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { memoize } from './memoize';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { withCacheKey, withoutConcurrentExecution } from './memoize';
|
||||
|
||||
describe('memoize', () => {
|
||||
it('should memoize the function', async () => {
|
||||
const fn = mock(async () => Math.random());
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized()).toBe(await memoized());
|
||||
});
|
||||
describe('withoutConcurrentExecution', () => {
|
||||
it('should memoize the function based on the cache key', async () => {
|
||||
const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b);
|
||||
const memoized = withoutConcurrentExecution(() => null, fn);
|
||||
|
||||
it('should memoize the function with different arguments', async () => {
|
||||
const fn = mock(async (a: number, b: number) => a + b);
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
|
||||
const p1 = memoized('c1', 1, 2);
|
||||
const p2 = memoized('c1', 1, 2);
|
||||
const p3 = memoized('c3', 2, 3);
|
||||
|
||||
expect(await p1).toBe(await p2);
|
||||
expect(await p1).not.toBe(await p3);
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should memoize a function complex object', async () => {
|
||||
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
|
||||
await memoized({ foo: 'foo', bar: 2 })
|
||||
);
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should wrap concurrent async calls', async () => {
|
||||
it('should support caching per request', async () => {
|
||||
const fn = mock(async () => Math.random());
|
||||
const memoized = memoize(fn);
|
||||
const promise1 = memoized();
|
||||
const promise2 = memoized();
|
||||
|
||||
const request1 = { id: 'request1' };
|
||||
const request2 = { id: 'request2' };
|
||||
|
||||
const requestContext = new AsyncLocalStorage<{ id: string }>();
|
||||
|
||||
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn);
|
||||
|
||||
// Both in the same request
|
||||
const promise1 = requestContext.run(request1, () => memoized('c1'));
|
||||
const promise2 = requestContext.run(request1, () => memoized('c1'));
|
||||
|
||||
// In a different request
|
||||
const promise3 = requestContext.run(request2, () => memoized('c1'));
|
||||
|
||||
expect(await promise1).toBe(await promise2);
|
||||
expect(await promise1).not.toBe(await promise3);
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withCacheKey', () => {
|
||||
it('should wrap the function by passing the cache key', async () => {
|
||||
const fn = mock(
|
||||
async (cacheKey: string, arg: { a: number; b: number }, c: number) =>
|
||||
`${cacheKey}, result=${arg.a + arg.b + c}`
|
||||
);
|
||||
const memoized = withCacheKey(fn);
|
||||
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,61 @@
|
||||
import pMemoize from 'p-memoize';
|
||||
/**
|
||||
* Wrap a function by preventing concurrent executions of the same function.
|
||||
* With a logic to work per-request in Cloudflare Workers.
|
||||
*/
|
||||
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
|
||||
getGlobalContext: () => object | null | undefined,
|
||||
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
|
||||
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
|
||||
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
|
||||
|
||||
return (key: string, ...args: ArgsType) => {
|
||||
const globalContext = getGlobalContext() ?? globalThis;
|
||||
|
||||
/**
|
||||
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
|
||||
* to avoid "Cannot perform I/O on behalf of a different request" errors.
|
||||
*/
|
||||
const promiseCache =
|
||||
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
|
||||
globalPromiseCache.set(globalContext, promiseCache);
|
||||
|
||||
const concurrent = promiseCache.get(key);
|
||||
if (concurrent) {
|
||||
return concurrent;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await wrapped(key, ...args);
|
||||
return result;
|
||||
} finally {
|
||||
promiseCache.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
promiseCache.set(key, promise);
|
||||
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* We wrap 'use cache' calls in a p-memoize function to avoid
|
||||
* executing the function multiple times when doing concurrent calls.
|
||||
*
|
||||
* Hopefully one day this can be done directly by 'use cache'.
|
||||
* Wrap a function by passing it a cache key that is computed from the function arguments.
|
||||
*/
|
||||
export function memoize<F extends (...args: any[]) => any>(f: F): F {
|
||||
return pMemoize(f, {
|
||||
cacheKey: (args) => {
|
||||
return JSON.stringify(deepSortValue(args));
|
||||
},
|
||||
});
|
||||
export function withCacheKey<ArgsType extends any[], ReturnType>(
|
||||
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
|
||||
): (...args: ArgsType) => Promise<ReturnType> {
|
||||
return (...args: ArgsType) => {
|
||||
const cacheKey = getCacheKey(args);
|
||||
return wrapped(cacheKey, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a cache key from the function arguments.
|
||||
*/
|
||||
function getCacheKey(args: any[]) {
|
||||
return JSON.stringify(deepSortValue(args));
|
||||
}
|
||||
|
||||
function deepSortValue(value: unknown): unknown {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
|
||||
|
||||
describe('isProxyRequest', () => {
|
||||
it('should return true for proxy requests', () => {
|
||||
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
|
||||
expect(isProxyRequest(proxyRequestURL)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-proxy requests', () => {
|
||||
const nonProxyRequestURL = new URL('https://example.com/docs/foo/hello/world');
|
||||
expect(isProxyRequest(nonProxyRequestURL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProxyRequestIdentifier', () => {
|
||||
it('should return the correct identifier for proxy requests', () => {
|
||||
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
|
||||
expect(getProxyRequestIdentifier(proxyRequestURL)).toBe('sites/site_foo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Check if the request to the site was through a proxy.
|
||||
*/
|
||||
export function isProxyRequest(requestURL: URL): boolean {
|
||||
return (
|
||||
requestURL.host === 'proxy.gitbook.site' || requestURL.host === 'proxy.gitbook-staging.site'
|
||||
);
|
||||
}
|
||||
|
||||
export function getProxyRequestIdentifier(requestURL: URL): string {
|
||||
// For proxy requests, we extract the site ID from the pathname
|
||||
// e.g. https://proxy.gitbook.site/site/siteId/...
|
||||
const pathname = requestURL.pathname.slice(1).split('/');
|
||||
return pathname.slice(0, 2).join('/');
|
||||
}
|
||||
@@ -97,6 +97,15 @@ export interface GitBookDataFetcher {
|
||||
path: string;
|
||||
}): 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.
|
||||
*/
|
||||
@@ -170,4 +179,15 @@ export interface GitBookDataFetcher {
|
||||
integrationName: string;
|
||||
request: api.RenderIntegrationUI;
|
||||
}): 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runtime environment.
|
||||
*/
|
||||
export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as
|
||||
| 'vercel'
|
||||
| 'cloudflare'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Main host on which GitBook is running.
|
||||
*/
|
||||
@@ -77,6 +85,15 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
|
||||
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
||||
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
||||
|
||||
/**
|
||||
* Mode used for resizing images.
|
||||
*/
|
||||
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
|
||||
'GITBOOK_IMAGE_RESIZE_MODE',
|
||||
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
|
||||
['cdn-cgi', 'cf-fetch']
|
||||
);
|
||||
|
||||
/**
|
||||
* Endpoint where icons are served.
|
||||
*/
|
||||
@@ -92,3 +109,12 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
|
||||
* Secret used to validate requests from the GitBook app.
|
||||
*/
|
||||
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
||||
|
||||
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
|
||||
if (!enumValues.includes(value as T)) {
|
||||
throw new Error(
|
||||
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||
import type { GitBookLinker } from '../links';
|
||||
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||
import { getImageSize } from './resizer';
|
||||
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||
import type { ImageResizer } from './types';
|
||||
|
||||
interface CloudflareImageJsonFormat {
|
||||
width: number;
|
||||
height: number;
|
||||
original: {
|
||||
file_size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
|
||||
*/
|
||||
export interface CloudflareImageOptions {
|
||||
format?: 'webp' | 'avif' | 'json' | 'jpeg';
|
||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpr?: number;
|
||||
anim?: boolean;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image resizer for a rendering context.
|
||||
*/
|
||||
export function createImageResizer({
|
||||
host,
|
||||
imagesContextId,
|
||||
linker,
|
||||
}: {
|
||||
/** The linker to use to create URLs. */
|
||||
linker: GitBookLinker;
|
||||
/** The host name of the current site. */
|
||||
host: string;
|
||||
/** The site identifier to use for verifying the image signature. */
|
||||
imagesContextId: string;
|
||||
}): ImageResizer {
|
||||
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
||||
return createNoopImageResizer();
|
||||
@@ -47,7 +24,7 @@ export function createImageResizer({
|
||||
|
||||
return {
|
||||
getResizedImageURL: (urlInput) => {
|
||||
if (!checkIsSizableImageURL(urlInput)) {
|
||||
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,7 +35,7 @@ export function createImageResizer({
|
||||
|
||||
return async (options) => {
|
||||
cachedSignature ??= await generateImageSignature({
|
||||
host,
|
||||
imagesContextId,
|
||||
url: urlInput,
|
||||
});
|
||||
|
||||
@@ -87,7 +64,7 @@ export function createImageResizer({
|
||||
},
|
||||
|
||||
getImageSize: async (input, options) => {
|
||||
if (!checkIsSizableImageURL(input)) {
|
||||
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
|
||||
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.
|
||||
* 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 './signatures';
|
||||
export * from './utils';
|
||||
export * from './getImageResizingContextId';
|
||||
export * from './resizer';
|
||||
export * from './checkIsSizableImageURL';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
|
||||
import type { CloudflareImageOptions } from './types';
|
||||
import { copyImageResponse } from './utils';
|
||||
|
||||
/**
|
||||
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
|
||||
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
*/
|
||||
export async function resizeImageWithCDNCgi(
|
||||
input: string,
|
||||
options: CloudflareImageOptions & {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { signal, ...resizeOptions } = options;
|
||||
|
||||
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
|
||||
// we need to use the Cloudflare Image Resize API directly.
|
||||
if (!GITBOOK_IMAGE_RESIZE_URL) {
|
||||
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
|
||||
}
|
||||
|
||||
const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
|
||||
resizeOptions
|
||||
)}/${encodeURIComponent(input)}`;
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
|
||||
console.log(`resize image using cdn-cgi: ${resizeURL}`);
|
||||
|
||||
return copyImageResponse(
|
||||
await fetch(resizeURL, {
|
||||
headers: {
|
||||
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||
Accept:
|
||||
resizeOptions.format === 'json'
|
||||
? 'application/json'
|
||||
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyOptions(options: CloudflareImageOptions): string {
|
||||
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
|
||||
return `${rest}${rest ? ',' : ''}${key}=${value}`;
|
||||
}, '');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CloudflareImageOptions } from './types';
|
||||
import { copyImageResponse } from './utils';
|
||||
|
||||
/**
|
||||
* Resize an image by doing a request to the image itself using the Cloudflare fetch.
|
||||
* https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
*
|
||||
* This method doesn't work in Cloudflare Pages and is only supported in workers.
|
||||
*/
|
||||
export async function resizeImageWithCFFetch(
|
||||
input: string,
|
||||
options: CloudflareImageOptions & {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { signal, ...resizeOptions } = options;
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
|
||||
console.log(`resize image using cf-fetch: ${input}`);
|
||||
|
||||
return copyImageResponse(
|
||||
await fetch(input, {
|
||||
headers: {
|
||||
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||
Accept:
|
||||
resizeOptions.format === 'json'
|
||||
? 'application/json'
|
||||
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||
},
|
||||
signal,
|
||||
cf: { image: resizeOptions },
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './resizeImage';
|
||||
@@ -0,0 +1,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 = {
|
||||
url: string;
|
||||
host: string;
|
||||
imagesContextId: string;
|
||||
};
|
||||
|
||||
type SignFn = (input: SignFnInput) => MaybePromise<string>;
|
||||
@@ -32,6 +32,11 @@ export async function verifyImageSignature(
|
||||
): Promise<boolean> {
|
||||
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
||||
const generated = await generator(input);
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
|
||||
console.log(
|
||||
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
|
||||
);
|
||||
return generated === signature;
|
||||
}
|
||||
|
||||
@@ -60,12 +65,14 @@ const generateSignatureV2: SignFn = async (input) => {
|
||||
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||
const all = [
|
||||
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,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(':');
|
||||
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
||||
|
||||
const signature = fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
||||
return signature;
|
||||
};
|
||||
|
||||
// Reused buffer for FNV-1a hashing in the v1 algorithm
|
||||
|
||||
@@ -93,6 +93,11 @@ export function createLinker(
|
||||
},
|
||||
|
||||
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) {
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
import type { SiteURLData } from './context';
|
||||
|
||||
export enum MiddlewareHeaders {
|
||||
/**
|
||||
@@ -57,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
|
||||
* Get the site URL data from the middleware headers.
|
||||
* 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 siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
||||
|
||||
|
||||
@@ -10,19 +10,22 @@ import {
|
||||
type ResponseCookies,
|
||||
getPathScopedCookieName,
|
||||
getResponseCookiesForVisitorAuth,
|
||||
getVisitorToken,
|
||||
getVisitorData,
|
||||
normalizeVisitorAuthURL,
|
||||
} from '@/lib/visitor-token';
|
||||
} from '@/lib/visitors';
|
||||
import { serveResizedImage } from '@/routes/image';
|
||||
import {
|
||||
DataFetcherError,
|
||||
getPublishedContentByURL,
|
||||
getVisitorAuthBasePath,
|
||||
normalizeURL,
|
||||
resolvePublishedContentByUrl,
|
||||
throwIfDataError,
|
||||
} from '@v2/lib/data';
|
||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
||||
import { getImageResizingContextId } from '@v2/lib/images';
|
||||
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
||||
|
||||
import type { SiteURLData } from './lib/context';
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
|
||||
@@ -31,6 +34,15 @@ export const config = {
|
||||
|
||||
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) {
|
||||
try {
|
||||
const requestURL = new URL(request.url);
|
||||
@@ -63,7 +75,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
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`).
|
||||
@@ -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 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, {
|
||||
host: siteURL.host,
|
||||
imagesContextId: imagesContextId,
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Detect and extract the visitor authentication token from the request
|
||||
//
|
||||
// @ts-ignore - request typing
|
||||
const visitorToken = getVisitorToken({
|
||||
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({
|
||||
cookies: request.cookies.getAll(),
|
||||
url: siteURL,
|
||||
url: siteRequestURL,
|
||||
});
|
||||
|
||||
const withAPIToken = async (apiToken: string | null) => {
|
||||
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
|
||||
? resolvePublishedContentByUrl
|
||||
: getPublishedContentByURL;
|
||||
const siteURLData = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
url: siteURL.toString(),
|
||||
visitorAuthToken: visitorToken?.token ?? null,
|
||||
resolve({
|
||||
url: siteRequestURL.toString(),
|
||||
visitorPayload: {
|
||||
jwtToken: visitorToken?.token ?? undefined,
|
||||
unsignedClaims,
|
||||
},
|
||||
// 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.
|
||||
// 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,
|
||||
})
|
||||
);
|
||||
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
|
||||
@@ -134,20 +158,32 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
return NextResponse.redirect(siteURLData.redirect);
|
||||
}
|
||||
|
||||
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
|
||||
cookies.push(
|
||||
...getResponseCookiesForVisitorAuth(
|
||||
getVisitorAuthBasePath(siteRequestURL, siteURLData),
|
||||
visitorToken
|
||||
)
|
||||
);
|
||||
|
||||
// We use the host/origin from the canonical URL to ensure the links are
|
||||
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
|
||||
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
|
||||
|
||||
let incomingURL = requestURL;
|
||||
// For cases where the site is proxied, we use the canonical URL
|
||||
// as the incoming URL along with all the search params from the request.
|
||||
if (mode !== 'url') {
|
||||
incomingURL = siteCanonicalURL;
|
||||
incomingURL.search = requestURL.search;
|
||||
}
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup
|
||||
// The token is stored in a cookie that is set on the redirect response
|
||||
//
|
||||
const incomingURL = mode === 'url' ? requestURL : siteURL;
|
||||
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||
if (
|
||||
requestURLWithoutToken !== incomingURL &&
|
||||
requestURLWithoutToken.toString() !== incomingURL.toString()
|
||||
) {
|
||||
const incomingURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||
if (incomingURLWithoutToken.toString() !== incomingURL.toString()) {
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
||||
NextResponse.redirect(incomingURLWithoutToken.toString()),
|
||||
cookies
|
||||
);
|
||||
}
|
||||
@@ -160,19 +196,39 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
// (customization override, theme, etc)
|
||||
let routeType: 'dynamic' | 'static' = 'static';
|
||||
|
||||
// We pick only stable data from the siteURL data to prevent re-rendering of
|
||||
// the root layout when changing pages..
|
||||
const stableSiteURLData: SiteURLData = {
|
||||
site: siteURLData.site,
|
||||
siteSection: siteURLData.siteSection,
|
||||
siteSpace: siteURLData.siteSpace,
|
||||
siteBasePath: siteURLData.siteBasePath,
|
||||
basePath: siteURLData.basePath,
|
||||
space: siteURLData.space,
|
||||
organization: siteURLData.organization,
|
||||
changeRequest: siteURLData.changeRequest,
|
||||
revision: siteURLData.revision,
|
||||
shareKey: siteURLData.shareKey,
|
||||
apiToken: siteURLData.apiToken,
|
||||
imagesContextId: imagesContextId,
|
||||
};
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${siteURLData.basePath}`);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
||||
requestHeaders.set(
|
||||
MiddlewareHeaders.SiteURL,
|
||||
`${siteCanonicalURL.origin}${siteURLData.basePath}`
|
||||
);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData));
|
||||
|
||||
// Preview of customization/theme
|
||||
const customization = siteURL.searchParams.get('customization');
|
||||
const customization = siteRequestURL.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
}
|
||||
const theme = siteURL.searchParams.get('theme');
|
||||
const theme = siteRequestURL.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
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('origin', request.nextUrl.origin);
|
||||
|
||||
const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
|
||||
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
|
||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||
siteURLData.pathname
|
||||
);
|
||||
@@ -200,12 +256,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
mode,
|
||||
encodeURIComponent(siteURLWithoutProtocol),
|
||||
encodeURIComponent(
|
||||
rison.encode({
|
||||
...siteURLData,
|
||||
// The pathname is passed as the next segment of the route and should not cause this segment to change
|
||||
// based on the page being visited
|
||||
pathname: '<DO_NOT_USE>',
|
||||
})
|
||||
rison.encode(
|
||||
// rison can't encode undefined values
|
||||
Object.fromEntries(
|
||||
Object.entries(stableSiteURLData).filter(
|
||||
([_, v]) => typeof v !== 'undefined'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
pathname,
|
||||
].join('/');
|
||||
@@ -233,10 +291,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
};
|
||||
|
||||
// For https://preview/<siteURL> requests,
|
||||
if (siteURL.hostname === 'preview') {
|
||||
if (siteRequestURL.hostname === 'preview') {
|
||||
return serveWithQueryAPIToken(
|
||||
// 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,
|
||||
withAPIToken
|
||||
);
|
||||
@@ -389,13 +447,26 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
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) {
|
||||
case '~gitbook/icon':
|
||||
return { pathname };
|
||||
case 'llms.txt':
|
||||
case 'sitemap.xml':
|
||||
case 'sitemap-pages.xml':
|
||||
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':
|
||||
// PDF routes are always dynamic as they depend on the search params.
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
### Patch Changes
|
||||
@@ -433,7 +610,7 @@
|
||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||
- 3996110: Optimize images rendered in community ads
|
||||
- 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
|
||||
- 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`
|
||||
|
||||
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
|
||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nexthink',
|
||||
contentBaseURL: 'https://docs.nexthink.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
screenshot: { waitForTOCScrolling: false },
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// name: 'Nexthink',
|
||||
// contentBaseURL: 'https://docs.nexthink.com',
|
||||
// tests: [
|
||||
// {
|
||||
// name: 'Home',
|
||||
// url: '/',
|
||||
// screenshot: { waitForTOCScrolling: false },
|
||||
// run: waitForCookiesDialog,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||
@@ -92,11 +92,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://book.character.ai',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.tradeonnova.io',
|
||||
contentBaseURL: 'https://docs.tradeonnova.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'azcoiner.gitbook.io',
|
||||
contentBaseURL: 'https://azcoiner.gitbook.io',
|
||||
@@ -162,11 +157,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://wiki.redmodding.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cherry-ai.com',
|
||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.cherry-ai.com',
|
||||
// contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.snyk.io',
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
@@ -244,6 +239,15 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.fluentbit.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'run-ai-docs.nvidia.com',
|
||||
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
runTestCases(testCases);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
VISITOR_TOKEN_COOKIE,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
} from '@/lib/visitor-token';
|
||||
} from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
@@ -110,24 +111,24 @@ const testCases: TestsCase[] = [
|
||||
name: 'Customized variant titles are displayed',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = page
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="space-dropdown-button"] + div'
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// the customized space title
|
||||
await expect(
|
||||
variantSelectionDropdown.getByRole('link', {
|
||||
variantSelectionDropdown.getByRole('menuitem', {
|
||||
name: 'Multi-Variants',
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
// the NON-customized space title
|
||||
await expect(
|
||||
variantSelectionDropdown.getByRole('link', {
|
||||
variantSelectionDropdown.getByRole('menuitem', {
|
||||
name: 'RFCs',
|
||||
})
|
||||
).toBeVisible();
|
||||
@@ -144,14 +145,17 @@ const testCases: TestsCase[] = [
|
||||
url: 'api-multi-versions/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// Click the second variant in the dropdown
|
||||
await page
|
||||
.getByRole('link', {
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: '2.0',
|
||||
})
|
||||
.click();
|
||||
@@ -167,14 +171,18 @@ const testCases: TestsCase[] = [
|
||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
await page
|
||||
.getByRole('link', {
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: '2.0',
|
||||
})
|
||||
.click();
|
||||
@@ -204,14 +212,18 @@ const testCases: TestsCase[] = [
|
||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||
},
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
await page
|
||||
.getByRole('link', {
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: '2.0',
|
||||
})
|
||||
.click();
|
||||
@@ -257,7 +269,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'GitBook',
|
||||
contentBaseURL: 'https://docs.gitbook.com',
|
||||
contentBaseURL: 'https://gitbook.com/docs/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
@@ -308,6 +320,18 @@ const testCases: TestsCase[] = [
|
||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||||
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',
|
||||
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',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type CustomizationThemedColor,
|
||||
type SiteCustomizationSettings,
|
||||
} 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 rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
@@ -33,7 +33,11 @@ export interface Test {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -138,6 +142,11 @@ export async function waitForCookiesDialog(page: Page) {
|
||||
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.
|
||||
*/
|
||||
@@ -149,6 +158,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
|
||||
test.describe(testCase.name, () => {
|
||||
for (const testEntry of testCase.tests) {
|
||||
const { mode = 'page' } = testEntry;
|
||||
const testFn = testEntry.only ? test.only : test;
|
||||
testFn(testEntry.name, async ({ page, context }) => {
|
||||
const testEntryPathname =
|
||||
@@ -158,6 +168,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
||||
)
|
||||
: getTestURL(testEntryPathname);
|
||||
|
||||
if (testEntry.cookies) {
|
||||
await context.addCookies(
|
||||
testEntry.cookies.map((cookie) => ({
|
||||
@@ -183,30 +194,39 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(url);
|
||||
const response = await page.goto(url);
|
||||
if (testEntry.run) {
|
||||
await testEntry.run(page);
|
||||
await testEntry.run(page, response);
|
||||
}
|
||||
const screenshotOptions = testEntry.screenshot;
|
||||
if (screenshotOptions !== false) {
|
||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
||||
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||
argosCSS: `
|
||||
const screenshotName = `${testCase.name} - ${testEntry.name}`;
|
||||
if (mode === 'image') {
|
||||
await argosScreenshot(page, screenshotName, {
|
||||
viewports: ['macbook-13'],
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: true,
|
||||
});
|
||||
} else {
|
||||
await argosScreenshot(page, screenshotName, {
|
||||
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
await waitForIcons(page);
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
},
|
||||
});
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
await waitForIcons(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -271,6 +291,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
internationalization: {
|
||||
locale: CustomizationLocale.En,
|
||||
},
|
||||
insights: {
|
||||
trackingCookie: true,
|
||||
},
|
||||
favicon: {},
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.Default,
|
||||
@@ -323,61 +346,78 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
async function waitForIcons(page: Page) {
|
||||
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 results = icons.map((icon) => {
|
||||
if (!(icon instanceof SVGElement)) {
|
||||
throw new Error('Icon is not an SVGElement');
|
||||
}
|
||||
|
||||
// If loaded, good it passes the test.
|
||||
if (icon.dataset.loadingState === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not loaded yet, we need to load it.
|
||||
if (icon.dataset.loadingState === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore icons that are not visible.
|
||||
if (!icon.checkVisibility()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const state = icon.getAttribute('data-argos-state');
|
||||
|
||||
if (state === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
const url = urlMatch?.[1];
|
||||
|
||||
// If URL is invalid we throw an error.
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
|
||||
// If the URL is already loaded, we just mark it as loaded.
|
||||
if (urls.has(url)) {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
return true;
|
||||
// If the URL is already queued for loading, we return the state.
|
||||
if (urlStates[url]) {
|
||||
if (urlStates[url].state === 'loaded') {
|
||||
icon.setAttribute('data-argos-state', 'pending');
|
||||
icon.style.maskImage = `url("${urlStates[url].uri}")`;
|
||||
requestAnimationFrame(() => {
|
||||
icon.setAttribute('data-argos-state', 'loaded');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark the icon as pending and load the image.
|
||||
icon.dataset.loadingState = 'pending';
|
||||
|
||||
// Mark the URL as seen.
|
||||
urls.add(url);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.decode().then(() => {
|
||||
// Wait two frames to let the time to the icon to repaint.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
loadUrl(url);
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ module.exports = {
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||
},
|
||||
|
||||
webpack(config) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.8.2",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/api": "^0.115.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
@@ -27,19 +27,19 @@
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@upstash/redis": "^1.27.1",
|
||||
"ai": "^4.1.46",
|
||||
"ajv": "^8.12.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
"holy-loader": "^2.3.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
@@ -47,15 +47,16 @@
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.17",
|
||||
"next": "14.2.25",
|
||||
"next": "14.2.26",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.0",
|
||||
"parse-cache-control": "^1.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"partial-json": "^0.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -69,11 +70,14 @@
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"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": {
|
||||
"@argos-ci/playwright": "^4.3.0",
|
||||
"@cloudflare/next-on-pages": "1.13.7",
|
||||
"@argos-ci/playwright": "^5.0.3",
|
||||
"@cloudflare/next-on-pages": "1.13.12",
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# GitBook immutable static assets
|
||||
# Duplicated from next.config.mjs until OpenNext supports generating static headers
|
||||
/~gitbook/static/*
|
||||
cache-control: public,max-age=31536000,immutable
|
||||
Access-Control-Allow-Origin: *
|
||||
/_next/static/*
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
@@ -14,7 +14,15 @@ interface JsonBody {
|
||||
* The body should be a JSON with { tags: string[] }
|
||||
*/
|
||||
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)) {
|
||||
return NextResponse.json(
|
||||
@@ -25,10 +33,18 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await revalidateTags(json.tags);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats: result.stats,
|
||||
});
|
||||
try {
|
||||
const result = await revalidateTags(json.tags);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
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 }) {
|
||||
// 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
|
||||
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
|
||||
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export default function ErrorPage(props: {
|
||||
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
>
|
||||
{t(language, 'unexpected_error_retry')}
|
||||
</Button>
|
||||
label={tString(language, 'unexpected_error_retry')}
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,10 @@
|
||||
import * as storage from '@/lib/local-storage';
|
||||
import type { ResolvedContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { CustomizationAnnouncement } from '@gitbook/api';
|
||||
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import Link from 'next/link';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { linkStyles } from '../primitives';
|
||||
import { Link, linkStyles } from '../primitives';
|
||||
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
@@ -26,52 +25,67 @@ export function AnnouncementBanner(props: {
|
||||
const style = BANNER_STYLES[announcement.style];
|
||||
|
||||
return (
|
||||
<div className="announcement-banner scroll-nojump theme-bold:bg-header-background pt-4 pb-2">
|
||||
<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
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
<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="close" className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ANNOUNCEMENT_CSS_CLASS,
|
||||
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||
@@ -18,6 +19,15 @@ export function AnnouncementDismissedScript() {
|
||||
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
|
||||
|
||||
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
onClick={() => {
|
||||
onUpdateState(true);
|
||||
}}
|
||||
>
|
||||
{t(language, 'cookies_accept')}
|
||||
</Button>
|
||||
label={tString(language, 'cookies_accept')}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
onClick={() => {
|
||||
onUpdateState(false);
|
||||
}}
|
||||
>
|
||||
{t(language, 'cookies_reject')}
|
||||
</Button>
|
||||
label={tString(language, 'cookies_reject')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
|
||||
import { List } from './List';
|
||||
import { ListItem } from './ListItem';
|
||||
import { BlockMath } from './Math';
|
||||
import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
|
||||
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
|
||||
import { Paragraph } from './Paragraph';
|
||||
import { Quote } from './Quote';
|
||||
import { ReusableContent } from './ReusableContent';
|
||||
@@ -85,6 +85,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
return <OpenAPIOperation {...props} block={block} />;
|
||||
case 'openapi-schemas':
|
||||
return <OpenAPISchemas {...props} block={block} />;
|
||||
case 'openapi-webhook':
|
||||
return <OpenAPIWebhook {...props} block={block} />;
|
||||
case 'embed':
|
||||
return <Embed {...props} block={block} />;
|
||||
case 'blockquote':
|
||||
@@ -159,6 +161,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
||||
case 'swagger':
|
||||
case 'openapi-operation':
|
||||
case 'openapi-schemas':
|
||||
case 'openapi-webhook':
|
||||
case 'math':
|
||||
case 'divider':
|
||||
case 'content-ref':
|
||||
|
||||
@@ -26,6 +26,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
||||
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
|
||||
// Preload the highlighter when the block is mounted.
|
||||
useEffect(() => {
|
||||
@@ -77,6 +78,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
let cancelled = false;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
setHighlighting(true);
|
||||
import('./highlight').then(({ highlight }) => {
|
||||
highlight(block, inlines).then((lines) => {
|
||||
if (cancelled) {
|
||||
@@ -84,6 +86,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
}
|
||||
|
||||
setLines(lines);
|
||||
setHighlighting(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -98,6 +101,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
}, [isInViewport, block, inlines]);
|
||||
|
||||
return (
|
||||
<CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines ?? plainLines} />
|
||||
<CodeBlockRenderer
|
||||
ref={blockRef}
|
||||
aria-busy={highlighting}
|
||||
block={block}
|
||||
style={style}
|
||||
lines={lines ?? plainLines}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
.highlight-line-number {
|
||||
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
|
||||
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
|
||||
@apply before:text-tint before:content-[counter(line)];
|
||||
|
||||
.highlight-line.highlighted > & {
|
||||
|
||||
@@ -14,6 +14,7 @@ import './CodeBlockRenderer.css';
|
||||
|
||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
lines: HighlightLine[];
|
||||
'aria-busy'?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
props: CodeBlockRendererProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const { block, style, lines } = props;
|
||||
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
||||
|
||||
const id = useId();
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
@@ -31,10 +32,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
const title = block.data.title;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
|
||||
<div
|
||||
ref={ref}
|
||||
aria-busy={ariaBusy}
|
||||
className={tcls('group/codeblock grid grid-flow-col', style)}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
||||
{title ? (
|
||||
<div className="inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s bg-tint px-3 py-2 text-tint text-xs leading-none tracking-wide">
|
||||
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -45,15 +50,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]',
|
||||
'rounded-md straight-corners:rounded-sm',
|
||||
'hide-scroll relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
|
||||
'rounded-md straight-corners:rounded-sm shadow-sm',
|
||||
title && 'rounded-ss-none'
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user