Compare commits

..

1 Commits

Author SHA1 Message Date
Greg Bergé 5fdb854400 Add loading indicator 2025-03-26 21:48:33 +01:00
575 changed files with 13210 additions and 21795 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix OpenAPI tabs indicator overflow
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix XML in code sample
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Disallow crawling by web-robots of search/ask URLs
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Safe parse OpenAPI JSON schema
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Fix multiple request examples selector not showing
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support site announcement banner
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix a crash when a page contains a block of an integration that is no longer installed
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix flash when loading sites with dark mode as default theme
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Fix spec properties rendering and missing keys
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Replace $ref with $reference in json-decycle
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Trim the search query to avoid showing a loading state when typing
-3
View File
@@ -1,3 +0,0 @@
# Changes to the API data cache functions can invalidate all existing data cache
# causing a massive amount of revalidation, impacting our API.
packages/gitbook/src/lib/data/api.ts @SamyPesse
+6 -29
View File
@@ -53,42 +53,19 @@ After forking this repository, you'll want to [create a branch](https://docs.git
#### 3. Install dependencies and run the project locally #### 3. Install dependencies and run the project locally
##### Prerequisites: GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
- Node.js (Version: >=20.6)
- Use `nvm` for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15)
- We use a text-based lockfile which isn't supported below 1.2.15
##### Setup steps: You can easily do this by running the command `nvm use`.
1. Ensure you are using the project's version of Node: To start your local version of GitBook, run the command `bun dev`.
```bash
nvm use
```
2. Install dependencies using Bun:
```bash
bun install
```
3. Start the development server:
```bash
bun dev
```
Additional development commands:
- `bun format`: Format the code using Biome
- `bun typecheck`: Run TypeScript type checking
- `bun unit`: Run unit tests
- `bun e2e`: Run end-to-end tests
#### 4. Preview your changes #### 4. Preview your changes
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/url`. 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/url/gitbook.com/docs` after running the development server. 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.
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. 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.
### Commit your update ### Commit your update
@@ -1,83 +0,0 @@
name: Gradual Deploy to Cloudflare
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
inputs:
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
middlewareVersionId:
description: 'Middleware version ID to deploy'
required: true
serverVersionId:
description: 'Server version ID to deploy'
required: true
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- id: wrangler_status
name: Check wrangler deployment status
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# This step is used to get the version ID that is currently deployed to Cloudflare.
- id: extract_current_version
name: Extract current version
shell: bash
run: |
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- id: deploy_server
name: Deploy server to Cloudflare at 0%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
- id: deploy_middleware
name: Deploy middleware to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Deploy server to Cloudflare at 100%
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
@@ -19,16 +19,10 @@ inputs:
deploy: deploy:
description: 'Deploy as main version for all traffic instead of uploading versions' description: 'Deploy as main version for all traffic instead of uploading versions'
required: true required: true
commitTag:
description: 'Commit branch to associate with the deployment'
required: true
commitMessage:
description: 'Commit message to associate with the deployment'
required: true
outputs: outputs:
deployment-url: deployment-url:
description: "Deployment URL" description: "Deployment URL"
value: ${{ steps.upload_middleware.outputs.deployment-url }} value: ${{ steps.deploy.outputs.deployment-url }}
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
@@ -55,84 +49,24 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker - name: Build worker
run: bun run turbo build:cloudflare run: bun run turbo build:v2:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
shell: bash shell: bash
- id: deploy
- name: Upload the DO worker name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0 uses: cloudflare/wrangler-action@v3.14.0
with: with:
apiToken: ${{ inputs.apiToken }} apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }} accountId: ${{ inputs.accountId }}
workingDirectory: ./ workingDirectory: ./
wranglerVersion: '4.10.0' wranglerVersion: '3.112.0'
environment: ${{ inputs.environment }} environment: ${{ inputs.environment }}
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
- id: upload_server
name: Upload server to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
- name: Extract server version worker ID
shell: bash
id: extract_server_version_id
run: |
version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- name: Run updateWrangler scripts
shell: bash
run: |
bun run ./packages/gitbook/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
- id: upload_middleware
name: Upload middleware to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '4.10.0'
environment: ${{ inputs.environment }}
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
- name: Extract middleware version worker ID
shell: bash
id: extract_middleware_version_id
run: |
version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
echo "version_id=$version_id" >> $GITHUB_OUTPUT
- name: Deploy server and middleware to Cloudflare
if: ${{ inputs.deploy == 'true' }}
uses: ./.github/actions/gradual-deploy-cloudflare
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
opServiceAccount: ${{ inputs.opServiceAccount }}
opItem: ${{ inputs.opItem }}
environment: ${{ inputs.environment }}
serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }}
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
deploy: ${{ inputs.deploy }}
- name: Outputs - name: Outputs
shell: bash shell: bash
env: env:
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }} DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: | run: |
echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}" echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
@@ -54,7 +54,6 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build Project Artifacts - name: Build Project Artifacts
@@ -63,7 +62,6 @@ runs:
env: env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }} VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }} VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
- name: Deploy Project Artifacts to Vercel - name: Deploy Project Artifacts to Vercel
id: deploy id: deploy
shell: bash shell: bash
+91 -48
View File
@@ -7,6 +7,52 @@ on:
env: env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs: jobs:
deploy-v1-cloudflare:
name: Deploy v1 to Cloudflare Pages
runs-on: ubuntu-latest
environment:
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
if: github.ref == 'refs/heads/main'
run: |
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
- name: Build Next.js with next-on-pages
run: bun run turbo gitbook#build:cloudflare
env:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ./
wranglerVersion: '3.112.0'
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
deploy-v2-vercel: deploy-v2-vercel:
name: Deploy v2 to Vercel (preview) name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -35,7 +81,7 @@ jobs:
name: 2c-preview name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }} url: ${{ steps.deploy.outputs.deployment-url }}
outputs: outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }} deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -49,24 +95,15 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-preview opItem: op://gitbook-open/2c-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
commitMessage: ${{ github.sha }}
- name: Extract Worker ID
id: extract-worker-id
if: ${{ !steps.deploy.outputs.deployment-url }}
run: |
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
- name: Outputs - name: Outputs
run: | run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}" echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
comment-deployments: comment-deployments:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Comment Deployments (preview) name: Comment Deployments (preview)
if: always() && !startsWith(github.ref, 'refs/heads/main') if: always() && !startsWith(github.ref, 'refs/heads/main')
needs: needs:
- deploy-v1-cloudflare
- deploy-v2-vercel - deploy-v2-vercel
- deploy-v2-cloudflare - deploy-v2-cloudflare
steps: steps:
@@ -86,6 +123,15 @@ jobs:
body: | body: |
Summary of the deployments: Summary of the deployments:
### Version 1 (production)
| 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 | URL | Status | | Version | URL | Status |
| --- | --- | --- | | --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} | | Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
@@ -93,11 +139,30 @@ jobs:
### Test content ### Test content
| Site | `2v` | `2c` | | Site | v1 | v2 |
| --- | --- | --- | | --- | --- | --- |
| GitBook | [${{ 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) | | 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-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) | | 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) |
edit-mode: replace edit-mode: replace
visual-testing-v1:
runs-on: ubuntu-latest
name: Visual Testing v1
needs: deploy-v1-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
env:
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
visual-testing-v2-vercel: visual-testing-v2-vercel:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing v2 name: Visual Testing v2
@@ -119,11 +184,11 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/ SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-vercel' ARGOS_BUILD_NAME: 'v2-vercel'
visual-testing-v2-cloudflare: visual-testing-customers-v1:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare) name: Visual Testing Customers v1
needs: deploy-v2-cloudflare needs: deploy-v1-cloudflare
timeout-minutes: 10 timeout-minutes: 6
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -134,17 +199,16 @@ jobs:
- name: Setup Playwright - name: Setup Playwright
uses: ./.github/actions/setup-playwright uses: ./.github/actions/setup-playwright
- name: Run Playwright tests - name: Run Playwright tests
run: bun e2e run: bun e2e-customers
env: env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }} BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare' ARGOS_BUILD_NAME: 'customers-v1'
visual-testing-customers-v2: visual-testing-customers-v2:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing Customers v2 name: Visual Testing Customers v2
needs: deploy-v2-vercel needs: deploy-v2-vercel
timeout-minutes: 10 timeout-minutes: 6
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -161,31 +225,10 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/ SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2' ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare: pagespeed-testing-v1:
runs-on: ubuntu-latest
name: Visual Testing Customers 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-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-v2:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: PageSpeed Testing v1 name: PageSpeed Testing v1
needs: deploy-v2-vercel needs: deploy-v1-cloudflare
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -198,5 +241,5 @@ jobs:
- name: Run pagespeed tests - name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts run: bun ./packages/gitbook/tests/pagespeed-testing.ts
env: env:
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}} BASE_URL: ${{needs.deploy-v1-cloudflare.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }} PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
+1 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy - name: Deploy staging
id: deploy id: deploy
uses: ./.github/composite/deploy-vercel uses: ./.github/composite/deploy-vercel
with: with:
@@ -48,8 +48,6 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-production opItem: op://gitbook-open/2c-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs - name: Outputs
run: | run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}" echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+1 -3
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy - name: Deploy staging
id: deploy id: deploy
uses: ./.github/composite/deploy-vercel uses: ./.github/composite/deploy-vercel
with: with:
@@ -48,8 +48,6 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-staging opItem: op://gitbook-open/2c-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs - name: Outputs
run: | run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}" echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
+19 -1
View File
@@ -39,4 +39,22 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview:
# For now it releases the cache-do to both preview and production
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
name: Release Preview
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Release preview packages
run: bun run release:preview
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-1
View File
@@ -9,7 +9,6 @@
], ],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"], "tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false, "prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit", "source.organizeImports.biome": "explicit",
+15 -10
View File
@@ -1,7 +1,7 @@
<h1 align="center">GitBook</h1> <h1 align="center">GitBook</h1>
<p align="center"> <p align="center">
<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> - <a href="https://github.com/orgs/GitbookIO/discussions/categories/feature-requests">Feature requests</a> <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>
</p> </p>
<p align="center"> <p align="center">
@@ -35,10 +35,10 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites ### Prerequisites
- Node.js (Version: >=20.6) - Node.js (Version: >=20.6)
- Use nvm for easy Node management - Use nvm for easy Node management
- [Bun](https://bun.sh/) (Version: >=1.2.15) - Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.15 - We use a text-based lockfile which isn't supported below 1.2.1
### Set up ### Set up
@@ -62,15 +62,20 @@ bun install
bun dev bun dev
``` ```
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/url`. 5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples: examples:
- http://localhost:3000/url/gitbook.com/docs - http://localhost:3000/docs.gitbook.com
- http://localhost:3000/url/open-source.gitbook.io/midjourney - http://localhost:3000/open-source.gitbook.io/midjourney
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser. Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
### Other development commands
- `bun format`: format the code
- `bun lint`: lint the code
### CI and testing ### CI and testing
All pull-requests will be tested against both visual and performances testing to prevent regressions. All pull-requests will be tested against both visual and performances testing to prevent regressions.
@@ -145,11 +150,11 @@ See `LICENSE` for more information.
</p> </p>
```md ```md
[![GitBook](https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1)](https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link) [![GitBook](https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1)](https://gitbook.com/)
``` ```
```html ```html
<a href="https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link"> <a href="https://gitbook.com">
<img <img
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1" src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
/> />
+2 -2
View File
@@ -20,8 +20,7 @@
"**/.wrangler/**/*", "**/.wrangler/**/*",
"packages/openapi-parser/src/fixtures/**/*", "packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts", "packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json", "packages/icons/src/data/*.json"
"packages/gitbook/worker-configuration.d.ts"
] ]
}, },
"formatter": { "formatter": {
@@ -127,6 +126,7 @@
{ {
"include": [ "include": [
"packages/gitbook/**/*", "packages/gitbook/**/*",
"packages/gitbook-v2/**/*",
"packages/react-openapi/**/*", "packages/react-openapi/**/*",
"packages/react-math/**/*", "packages/react-math/**/*",
"packages/react-contentkit/**/*", "packages/react-contentkit/**/*",
+531 -610
View File
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -4,20 +4,22 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12", "@changesets/cli": "^2.27.12",
"turbo": "^2.5.0", "turbo": "^2.4.4",
"vercel": "^39.3.0" "vercel": "^39.3.0"
}, },
"packageManager": "bun@1.2.15", "packageManager": "bun@1.2.5",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"react": "^19.0.0", "react": "18.3.1",
"react-dom": "^19.0.0", "react-dom": "18.3.1",
"esbuild": "0.24.2" "@gitbook/api": "0.102.0"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build", "build": "turbo run build",
"build:v2": "turbo run build:v2",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules", "clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"format": "biome check --write ./", "format": "biome check --write ./",
@@ -28,15 +30,11 @@
"changeset": "changeset", "changeset": "changeset",
"changeset-version": "changeset version && bun run format", "changeset-version": "changeset version && bun run format",
"release": "turbo run release && changeset publish", "release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean" "clean": "turbo run clean"
}, },
"workspaces": { "workspaces": ["packages/*"],
"packages": ["packages/*"],
"catalog": {
"@gitbook/api": "^0.128.0"
}
},
"patchedDependencies": { "patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", "decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch" "@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
+3
View File
@@ -0,0 +1,3 @@
.wrangler
worker-configuration.d.ts
dist/
+18
View File
@@ -0,0 +1,18 @@
# @gitbook/cache-do
## 0.1.1
### Patch Changes
- b7a5106: Disable cloudflare observability in production
## 0.1.0
### Minor Changes
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
### Patch Changes
- 56f5fa1: Enable Workers observability with a sampling of 0.1
+22
View File
@@ -0,0 +1,22 @@
# `@gitbook/cache-do`
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
### Performances
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
### Geo-distribution
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
### Concepts
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
**Location**: cache is distributed over 7 unique locations, one for each continent.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@gitbook/cache-do",
"type": "module",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
},
"./api": {
"types": "./dist/api.d.ts",
"development": "./src/api.ts",
"default": "./dist/api.js"
}
},
"version": "0.1.1",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^3.112.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"release": "wrangler deploy",
"release:preview": "wrangler deploy && wrangler deploy --env preview"
},
"files": ["dist", "src", "bin", "data", "README.md", "CHANGELOG.md"]
}
+292
View File
@@ -0,0 +1,292 @@
import { DurableObject } from 'cloudflare:workers';
import { decode, encode } from '@msgpack/msgpack';
import { LRUMap } from 'lru_map';
export interface CacheObjectDescriptor {
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
}
/**
* Value stored in a chunked binary msgpack format.
* Stored under the key `prop.${key}.${index}`.
*/
interface CacheObjectProp<Value = unknown> {
value: Value;
expiresAt: number;
}
/**
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
*/
interface CacheObjectExp {
/** Key of the property */
k: string;
/** Number of chunks */
c: number;
}
/**
* Durable Object class being deployed as a distributed cache.
*/
export class CacheObject extends DurableObject {
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
/**
* Open a descriptor to access the cache object.
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
* One session is opened per request on the client side and used to perform multiple operations.
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
*/
public open(): CacheObjectDescriptor {
return {
get: async <Value = unknown>(key: string) => {
return this.get<Value>(key);
},
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
await this.set(key, value, expiresAt);
},
};
}
/**
* Get the value of a property.
*/
public async get<Value = unknown>(key: string) {
return this.logOperation({ operation: 'get', key }, async (setLog) => {
// Try the memory state first.
const memoryEntry = this.lru.get(key);
if (memoryEntry) {
setLog({ memory: true });
setLog({ memoryMatch: !!memoryEntry.match });
if (!memoryEntry.match) {
return;
}
const isExpired = memoryEntry.match.expiresAt < Date.now();
setLog({ memoryExpired: isExpired });
if (!isExpired) {
return memoryEntry.match.value as Value;
}
}
return await this.getFromStorage<Value>(key);
});
}
/**
* Get the value of a property from the DO storage.
*/
public async getFromStorage<Value = unknown>(key: string) {
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
const entries = await this.ctx.storage.list<Uint8Array>({
prefix: getStoragePropKey(key),
noCache: true,
});
if (entries.size) {
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
if (entry && entry.value.expiresAt > Date.now()) {
// Found
this.lru.set(key, { match: entry.value });
return entry.value.value;
}
}
// Not found
this.lru.set(key, { match: undefined });
});
}
/**
* Set a value in the cache object.
*/
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return this.logOperation({ operation: 'set', key }, async (setLog) => {
const prop: CacheObjectProp<Value> = {
value,
expiresAt,
};
this.lru.set(key, { match: prop });
await this.ctx.storage.transaction(async (tx) => {
const entries = encodeChunks(key, prop);
const chunks = Object.keys(entries).length;
setLog({ chunks });
const clockValue: CacheObjectExp = {
k: key,
c: chunks,
};
await tx.put(getGCClockKey(key, expiresAt), clockValue);
await tx.put(entries);
const currentAlarm = await tx.getAlarm();
if (!currentAlarm) {
// Set an alarm to garbage collect all entries that have expired in 12h.
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
});
});
}
/**
* Purge all keys in the cache object.
*/
public async purge() {
return this.logOperation({ operation: 'purge' }, async (setLog) => {
const result = new Set<string>();
try {
// List all the keys in the cache object.
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
entries.forEach((exp) => {
result.add(exp.k);
});
} catch (_error) {}
await this.reset();
return Array.from(result);
});
}
/**
* Alarm to garbage collect all entries that have expired.
*/
async alarm() {
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
try {
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
const toDeleteSet = new Set<string>();
for (const [key, exp] of entries) {
const timestamp = Number.parseInt(key.split('.')[1]);
if (timestamp < Date.now()) {
toDeleteSet.add(key);
for (let i = 0; i < exp.c; i++) {
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
}
}
}
// Delete the keys by batch of 128.
const toDelete = Array.from(toDeleteSet);
setLog({ toDelete: toDelete.length });
for (let i = 0; i < toDelete.length; i += 128) {
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
}
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
if (toDelete.length) {
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
} catch (_error) {
await this.reset();
}
});
}
/**
* Reset the cache object.
*/
async reset() {
return this.logOperation({ operation: 'reset' }, async () => {
this.lru.clear();
await this.ctx.storage.deleteAll();
});
}
/**
* Time and log an operation.
*/
async logOperation<T>(
log: Record<string, unknown>,
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>
): Promise<T> {
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
const update: Record<string, unknown> = {};
const start = performance.now();
try {
return await fn((arg) => {
Object.assign(update, arg);
});
} finally {
const duration = performance.now() - start;
console.log({ ...log, ...update, objectId, duration });
}
}
}
function getStoragePropKey(key: string): string {
return `prop.${key}.`;
}
function getStoragePropChunkKey(key: string, index: number): string {
return `${getStoragePropKey(key)}${index}`;
}
function getGCClockRootKey(timestamp: number): string {
return `exp.${timestamp}.`;
}
function getGCClockKey(key: string, expiresAt: number): string {
return `${getGCClockRootKey(expiresAt)}${key}`;
}
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
const buf = encode(value);
const entries: Record<string, Uint8Array> = {};
const chunks = chunkUint8Array(buf, 128 * 1024);
for (let index = 0; index < chunks.length; index++) {
entries[getStoragePropChunkKey(key, index)] = chunks[index];
}
return entries;
}
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
const chunks = Array.from(entries.entries())
.map(([key, value]) => {
const index = Number.parseInt(key.split('.').pop()!);
return [index, value] as const;
})
.sort(([a], [b]) => a - b)
.map(([, value]) => value);
if (chunks.length === 0) {
return;
}
const buf = mergeUint8Array(chunks);
return { value: decode(buf) as T, size: buf.length };
}
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
const chunks: Uint8Array[] = [];
for (let i = 0; i < input.length; i += chunkSize) {
chunks.push(input.slice(i, i + chunkSize));
}
return chunks;
}
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
+97
View File
@@ -0,0 +1,97 @@
import type { CacheObject } from './CacheObject';
export type CacheLocationId = ContinentCode;
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
/**
* Location hint for the CacheObject durable object.
*/
const doLocationHints: {
[key in CacheLocationId]: DurableObjectLocationHint;
} = {
AF: 'afr',
AS: 'apac',
NA: 'wnam',
SA: 'sam',
AN: 'oc',
EU: 'weur',
OC: 'oc',
};
/**
* Client to access a cache tag.
*/
export class CacheObjectStub {
private stub: DurableObjectStub<CacheObject>;
constructor(
/** Binding to the CacheObject durable object */
private doNamespace: DurableObjectNamespace<CacheObject>,
/** ID of the location to target */
private locationId: CacheLocationId,
/** Name of the tag */
private tag: string
) {
const groupId = getCacheObjectIdName(this.locationId, this.tag);
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
}
/**
* Open a descriptor to the cache object.
* It can be used to perform multiple operations in a single RPC session.
* Ex:
* ```ts
* using desc = cache.open();
* await desc.set('key', 'value', Date.now() + 1000);
* await desc.get('key');
* ```
*/
async open() {
return await this.stub.open();
}
/**
* Get a value from the cache.
*/
async get<Value = unknown>(key: string) {
return (await this.stub.get(key)) as Value | undefined;
}
/**
* Set a value in the cache.
*/
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return await this.stub.set(key, value, expiresAt);
}
/**
* Purge all keys in the cache tag.
*/
async purge() {
const keys = new Set<string>();
await Promise.all(
allLocations.map(async (locationId) => {
const groupId = getCacheObjectIdName(locationId, this.tag);
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
const locationkeys = await cacheGroup.purge();
locationkeys.forEach((key) => keys.add(key));
})
);
return keys;
}
}
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
return `${locationId}:${tag}`;
}
+1
View File
@@ -0,0 +1 @@
export * from './CacheObjectStub';
+9
View File
@@ -0,0 +1,9 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
export * from './CacheObject';
export default class Worker extends WorkerEntrypoint {
fetch() {
return new Response('Hello, world!');
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"types": ["./.wrangler/types/runtime.d.ts"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
+27
View File
@@ -0,0 +1,27 @@
main = "./src/index.ts"
name = "gitbook-open-cache"
compatibility_date = "2024-09-02"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[observability]
enabled = false
[env.preview]
name = "gitbook-open-cache-preview"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[env.preview.observability]
enabled = true
head_sampling_rate = 0.1
-12
View File
@@ -1,17 +1,5 @@
# @gitbook/cache-tags # @gitbook/cache-tags
## 0.3.1
### Patch Changes
- 77397ca: Fix version of @gitbook/api referenced in package.json
## 0.3.0
### Minor Changes
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
## 0.2.0 ## 0.2.0
### Minor Changes ### Minor Changes
+2 -2
View File
@@ -8,9 +8,9 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.1", "version": "0.2.0",
"dependencies": { "dependencies": {
"@gitbook/api": "catalog:", "@gitbook/api": "*",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
+9 -32
View File
@@ -60,7 +60,7 @@ export function getCacheTag(
| { | {
tag: 'computed-document'; tag: 'computed-document';
space: string; space: string;
sourceType: string; integration: string;
} }
/** /**
* All data related to the URL of a content * All data related to the URL of a content
@@ -84,14 +84,6 @@ export function getCacheTag(
organization: string; organization: string;
openAPISpec: string; openAPISpec: string;
} }
/**
* All data related to a translation
*/
| {
tag: 'translation';
organization: string;
translationSettings: string;
}
): string { ): string {
switch (spec.tag) { switch (spec.tag) {
case 'user': case 'user':
@@ -107,15 +99,13 @@ export function getCacheTag(
case 'document': case 'document':
return `space:${spec.space}:document:${spec.document}`; return `space:${spec.space}:document:${spec.document}`;
case 'computed-document': case 'computed-document':
return `space:${spec.space}:computed-document:${spec.sourceType}`; return `space:${spec.space}:computed-document:${spec.integration}`;
case 'site': case 'site':
return `site:${spec.site}`; return `site:${spec.site}`;
case 'integration': case 'integration':
return `integration:${spec.integration}`; return `integration:${spec.integration}`;
case 'openapi': case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`; return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
default: default:
assertNever(spec); assertNever(spec);
} }
@@ -167,15 +157,6 @@ export function getComputedContentSourceCacheTags(
}) })
); );
break; break;
case 'translation-language':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
})
);
break;
default: default:
// Do not throw for unknown dependency types // Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version // as it might mean we are lacking behind the API version
@@ -188,22 +169,18 @@ export function getComputedContentSourceCacheTags(
getCacheTag({ getCacheTag({
tag: 'computed-document', tag: 'computed-document',
space: inContext.spaceId, space: inContext.spaceId,
sourceType: source.type, integration: source.integration,
}) })
); );
} }
// We invalidate the computed content when a new version of the integration is deployed. // We invalidate the computed content when a new version of the integration is deployed.
tags.push(
if (source.type.startsWith('integration:')) { getCacheTag({
const integration = source.type.split(':')[1]; tag: 'integration',
tags.push( integration: source.integration,
getCacheTag({ })
tag: 'integration', );
integration,
})
);
}
return tags; return tags;
} }
-20
View File
@@ -1,25 +1,5 @@
# @gitbook/colors # @gitbook/colors
## 0.3.3
### Patch Changes
- c3f6b8c: Update chroma ratio per step
- 5e975ab: Fix code highlighting for HTTP
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
## 0.3.2
### Patch Changes
- cdffd7c: Desaturate text colors by decreasing chroma for the last steps of the color scale
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0 ## 0.3.0
### Minor Changes ### Minor Changes
+1 -1
View File
@@ -8,7 +8,7 @@
"default": "./dist/index.js" "default": "./dist/index.js"
} }
}, },
"version": "0.3.3", "version": "0.3.0",
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
+2 -20
View File
@@ -214,31 +214,13 @@ export function colorScale(
const targetL = const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]); foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
if ( if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
index === 8 &&
!mix &&
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
) {
// Original colour is close enough to target, so let's use the original colour as step 9. // Original colour is close enough to target, so let's use the original colour as step 9.
result.push(hex); result.push(hex);
continue; continue;
} }
const chromaRatio = (() => { const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
switch (index) {
// Step 9 and 10 have max chroma, meaning they are fully saturated.
case 8:
case 9:
return 1;
// Step 11 and 12 have a reduced chroma
case 10:
return 0.4;
case 11:
return 0.1;
default:
return index * 0.05;
}
})();
const shade = { const shade = {
L: targetL, // Blend lightness L: targetL, // Blend lightness
-2
View File
@@ -1,2 +0,0 @@
dist/
src/data/*.json
-7
View File
@@ -1,7 +0,0 @@
# @gitbook/fonts
## 0.1.0
### Minor Changes
- fbfcca5: Initial version of the package
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/fonts`
Utilities to lookup default fonts supported by GitBook.
-91
View File
@@ -1,91 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { APIv2 } from 'google-font-metadata';
import { CustomizationDefaultFont } from '@gitbook/api';
import type { FontDefinitions } from '../src/types';
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
[CustomizationDefaultFont.Inter]: 'inter',
[CustomizationDefaultFont.FiraSans]: 'fira-sans-extra-condensed',
[CustomizationDefaultFont.IBMPlexSerif]: 'ibm-plex-serif',
[CustomizationDefaultFont.Lato]: 'lato',
[CustomizationDefaultFont.Merriweather]: 'merriweather',
[CustomizationDefaultFont.NotoSans]: 'noto-sans',
[CustomizationDefaultFont.OpenSans]: 'open-sans',
[CustomizationDefaultFont.Overpass]: 'overpass',
[CustomizationDefaultFont.Poppins]: 'poppins',
[CustomizationDefaultFont.Raleway]: 'raleway',
[CustomizationDefaultFont.Roboto]: 'roboto',
[CustomizationDefaultFont.RobotoSlab]: 'roboto-slab',
[CustomizationDefaultFont.SourceSansPro]: 'source-sans-3',
[CustomizationDefaultFont.Ubuntu]: 'ubuntu',
[CustomizationDefaultFont.ABCFavorit]: 'inter',
};
/**
* Scripts to generate the list of all icons.
*/
async function main() {
// @ts-expect-error - we build the object
const output: FontDefinitions = {};
for (const font of Object.values(CustomizationDefaultFont)) {
const googleFontName = googleFontsMap[font];
const fontMetadata = APIv2[googleFontName.toLowerCase()];
if (!fontMetadata) {
throw new Error(`Font ${googleFontName} not found`);
}
output[font] = {
font: googleFontName,
unicodeRange: fontMetadata.unicodeRange,
variants: {
'400': {},
'700': {},
},
};
Object.keys(output[font].variants).forEach((weight) => {
const variants = fontMetadata.variants[weight];
const normalVariant = variants.normal;
if (!normalVariant) {
throw new Error(`Font ${googleFontName} has no normal variant`);
}
output[font].variants[weight] = {};
Object.entries(normalVariant).forEach(([script, url]) => {
output[font].variants[weight][script] = url.url.woff;
});
});
}
await writeDataFile('fonts', JSON.stringify(output, null, 2));
}
/**
* We write both in dist and src as the build process might have happen already
* and tsc doesn't copy the files.
*/
async function writeDataFile(name, content) {
const srcData = path.resolve(__dirname, '../src/data');
const distData = path.resolve(__dirname, '../dist/data');
// Ensure the directories exists
await Promise.all([
fs.mkdir(srcData, { recursive: true }),
fs.mkdir(distData, { recursive: true }),
]);
await Promise.all([
fs.writeFile(path.resolve(srcData, `${name}.json`), content),
fs.writeFile(path.resolve(distData, `${name}.json`), content),
]);
}
main().catch((error) => {
console.error(`Error generating icons list: ${error}`);
process.exit(1);
});
-31
View File
@@ -1,31 +0,0 @@
{
"name": "@gitbook/fonts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.1.0",
"dependencies": {
"@gitbook/api": "catalog:"
},
"devDependencies": {
"google-font-metadata": "^6.0.3",
"typescript": "^5.5.3"
},
"scripts": {
"generate": "bun ./bin/generate.js",
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"clean": "rm -rf ./dist && rm -rf ./src/data",
"unit": "bun test"
},
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
"engines": {
"node": ">=20.0.0"
}
}
@@ -1,57 +0,0 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`getDefaultFont should return correct object for Latin text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should return correct object for Greek text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should handle mixed script text 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
}
`;
exports[`getDefaultFont should handle different font weights: regular 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different font weights: bold 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different fonts: inter 1`] = `
{
"font": "Inter",
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
}
`;
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
{
"font": "Roboto",
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
}
`;
-5
View File
@@ -1,5 +0,0 @@
import type { FontDefinitions } from './types';
import rawFonts from './data/fonts.json' with { type: 'json' };
export const fonts: FontDefinitions = rawFonts;
-119
View File
@@ -1,119 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { CustomizationDefaultFont } from '@gitbook/api';
import { getDefaultFont } from './getDefaultFont';
describe('getDefaultFont', () => {
it('should return null for invalid font', () => {
const result = getDefaultFont({
font: 'invalid-font' as CustomizationDefaultFont,
text: 'Hello',
weight: 400,
});
expect(result).toBeNull();
});
it('should return null for invalid weight', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 999 as any,
});
expect(result).toBeNull();
});
it('should return null for text not supported by any script', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: '😀', // Emoji not supported by Inter
weight: 400,
});
expect(result).toBeNull();
});
it('should return correct object for Latin text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello World',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should return correct object for Cyrillic text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Привет мир',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should return correct object for Greek text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Γεια σας',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should handle mixed script text', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello Привет',
weight: 400,
});
expect(result).not.toBeNull();
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
expect(result).toMatchSnapshot();
});
it('should handle different font weights', () => {
const regular = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 400,
});
const bold = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 700,
});
expect(regular).not.toBeNull();
expect(bold).not.toBeNull();
expect(regular).toMatchSnapshot('regular');
expect(bold).toMatchSnapshot('bold');
});
it('should handle empty string', () => {
const result = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: '',
weight: 400,
});
expect(result).toBeNull();
});
it('should handle different fonts', () => {
const inter = getDefaultFont({
font: CustomizationDefaultFont.Inter,
text: 'Hello',
weight: 400,
});
const roboto = getDefaultFont({
font: CustomizationDefaultFont.Roboto,
text: 'Hello',
weight: 400,
});
expect(inter).not.toBeNull();
expect(roboto).not.toBeNull();
expect(inter).toMatchSnapshot('inter');
expect(roboto).toMatchSnapshot('roboto');
});
});
-112
View File
@@ -1,112 +0,0 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
import { fonts } from './fonts';
import type { FontWeight } from './types';
/**
* Get the URL to load a font for a text.
*/
export function getDefaultFont(input: {
/**
* GitBook font to use.
*/
font: CustomizationDefaultFont;
/**
* Text to display with the font.
*/
text: string;
/**
* Font weight to use.
*/
weight: FontWeight;
}): { font: string; url: string } | null {
if (!input.text.trim()) {
return null;
}
const fontDefinition = fonts[input.font];
if (!fontDefinition) {
return null;
}
const variant = fontDefinition.variants[`${input.weight}`];
if (!variant) {
return null;
}
const script = getBestUnicodeRange(input.text, fontDefinition.unicodeRange);
if (!script) {
return null;
}
return variant[script]
? {
font: input.font,
url: variant[script],
}
: null;
}
/**
* Determine which named @font-face unicode-range covers
* the greatest share of the characters in `text`.
*
* @param text The text you want to inspect.
* @param ranges An object whose keys are range names and whose
* values are CSS-style comma-separated unicode-range
* declarations (e.g. "U+0370-03FF,U+1F00-1FFF").
* @returns The key of the best-matching range, or `null`
* when nothing matches at all.
*/
function getBestUnicodeRange(text: string, ranges: Record<string, string>): string | null {
// ---------- helper: parse "U+XXXX" or "U+XXXX-YYYY" ----------
const parseOne = (token: string): [number, number] | null => {
token = token.trim().toUpperCase();
if (!token.startsWith('U+')) return null;
const body = token.slice(2); // drop "U+"
const [startHex, endHex] = body.split('-');
const start = Number.parseInt(startHex, 16);
const end = endHex ? Number.parseInt(endHex, 16) : start;
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
return [start, end];
};
// ---------- helper: build lookup table ----------
const parsed: Record<string, [number, number][]> = {};
for (const [label, list] of Object.entries(ranges)) {
parsed[label] = list
.split(',')
.map(parseOne)
.filter((x): x is [number, number] => x !== null);
}
// ---------- tally code-point hits ----------
const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0]));
for (let i = 0; i < text.length; ) {
const cp = text.codePointAt(i)!;
i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates)
for (const [label, rangesArr] of Object.entries(parsed)) {
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
hits[label]++;
}
}
}
// ---------- choose the "best" ----------
let winner: string | null = null;
let maxCount = 0;
for (const [label, count] of Object.entries(hits)) {
if (count > maxCount) {
maxCount = count;
winner = label;
}
}
return maxCount > 0 ? winner : null;
}
-2
View File
@@ -1,2 +0,0 @@
export * from './getDefaultFont';
export * from './types';
-17
View File
@@ -1,17 +0,0 @@
import type { CustomizationDefaultFont } from '@gitbook/api';
export type FontWeight = 400 | 700;
export type FontDefinition = {
font: string;
unicodeRange: {
[script: string]: string;
};
variants: {
[weight in string]: {
[script: string]: string;
};
};
};
export type FontDefinitions = { [fontName in CustomizationDefaultFont]: FontDefinition };
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "src/**/*.test.ts"]
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"inputs": ["bin/**/*", "package.json"],
"outputs": ["src/data/*.json", "dist/data/*.json"]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
# Symbolic links
public
+41
View File
@@ -0,0 +1,41 @@
# gitbook-v2
## 0.2.2
### Patch Changes
- 54ee014: Add initial support for loading custom fonts
- bba2e52: Fix site redirects when it includes a section/variant path
## 0.2.1
### Patch Changes
- Updated dependencies [f32bf1f]
- @gitbook/cache-tags@0.2.0
## 0.2.0
### Minor Changes
- 76c7974: Add route to revalidate cached data
## 0.1.2
### Patch Changes
- 05ffd0e: Improving data cache management for computed content
- Updated dependencies [05ffd0e]
- @gitbook/cache-tags@0.1.0
## 0.1.1
### Patch Changes
- 3e11678: fix: lost section groups
## 0.1.0
### Minor Changes
- cfccc44: Setup structure and deployment for new version
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -7,6 +7,8 @@ const nextConfig = {
experimental: { experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation // This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true, authInterrupts: true,
// This is needed to use 'use cache'
useCache: true, useCache: true,
// Content is fully static, we can cache it in the session memory cache for a long time // Content is fully static, we can cache it in the session memory cache for a long time
@@ -31,9 +33,7 @@ const nextConfig = {
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX, GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET, GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL, GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
// Next.js envs // Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
@@ -53,24 +53,6 @@ const nextConfig = {
}, },
], ],
}, },
async headers() {
return [
{
source: '/~gitbook/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
],
},
];
},
}; };
export default nextConfig; export default nextConfig;
+10
View File
@@ -0,0 +1,10 @@
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';
export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
queue: memoryQueue,
tagCache: d1TagCache,
});
+36
View File
@@ -0,0 +1,36 @@
{
"name": "gitbook-v2",
"version": "0.2.2",
"private": true,
"dependencies": {
"next": "^15.2.3",
"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"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.10",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
"scripts": {
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
"dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test",
"typecheck": "tsc --noEmit"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,9 +1,9 @@
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@/app/utils';
import { import {
SitePage, SitePage,
generateSitePageMetadata, generateSitePageMetadata,
generateSitePageViewport, generateSitePageViewport,
} from '@/components/SitePage'; } from '@/components/SitePage';
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
type PageProps = { type PageProps = {
@@ -1,12 +1,12 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { import {
SiteLayout, SiteLayout,
generateSiteLayoutMetadata, generateSiteLayoutMetadata,
generateSiteLayoutViewport, generateSiteLayoutViewport,
} from '@/components/SiteLayout'; } from '@/components/SiteLayout';
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env'; import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
import { getThemeFromMiddleware } from '@/lib/middleware'; import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
interface SiteDynamicLayoutProps { interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
@@ -0,0 +1,12 @@
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);
}
@@ -0,0 +1,12 @@
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);
}
@@ -0,0 +1,12 @@
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);
}
@@ -0,0 +1,12 @@
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,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { serveIcon } from '@/routes/icon'; import { serveIcon } from '@/routes/icon';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -1,8 +1,8 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import type { PageIdParams } from '@/components/SitePage'; import type { PageIdParams } from '@/components/SitePage';
import { serveOGImage } from '@/routes/ogimage'; import { serveOGImage } from '@/routes/ogimage';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
@@ -1,5 +1,5 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { PDFRootLayout } from '@/components/PDF'; import { PDFRootLayout } from '@/components/PDF';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export default async function RootLayout(props: { export default async function RootLayout(props: {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
@@ -1,5 +1,5 @@
import { type RouteLayoutParams, getDynamicSiteContext } from '@/app/utils';
import { PDFPage, generatePDFMetadata } from '@/components/PDF'; import { PDFPage, generatePDFMetadata } from '@/components/PDF';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -1,11 +1,12 @@
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
import { import {
SitePage, SitePage,
generateSitePageMetadata, generateSitePageMetadata,
generateSitePageViewport, generateSitePageViewport,
} from '@/components/SitePage'; } from '@/components/SitePage';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { unstable_cacheTag as cacheTag } from 'next/cache';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -14,10 +15,19 @@ type PageProps = {
}; };
export default async function Page(props: PageProps) { export default async function Page(props: PageProps) {
'use cache';
const params = await props.params; const params = await props.params;
const { context } = await getStaticSiteContext(params); const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params); const pathname = getPagePathFromParams(params);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return <SitePage context={context} pageParams={{ pathname }} />; return <SitePage context={context} pageParams={{ pathname }} />;
} }
@@ -1,11 +1,13 @@
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { import {
SiteLayout, SiteLayout,
generateSiteLayoutMetadata, generateSiteLayoutMetadata,
generateSiteLayoutViewport, generateSiteLayoutViewport,
} from '@/components/SiteLayout'; } from '@/components/SiteLayout';
import { GITBOOK_DISABLE_TRACKING } from '@/lib/env'; 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 { interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
@@ -15,8 +17,17 @@ export default async function SiteStaticLayout({
params, params,
children, children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) { }: React.PropsWithChildren<SiteStaticLayoutProps>) {
'use cache';
const { context, visitorAuthClaims } = await getStaticSiteContext(await params); const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
cacheTag(
getCacheTag({
tag: 'site',
site: context.site.id,
})
);
return ( return (
<CustomizationRootLayout customization={context.customization}> <CustomizationRootLayout customization={context.customization}>
<SiteLayout <SiteLayout
@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveLLMsTxt } from '@/routes/llms'; import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -10,5 +10,5 @@ export async function GET(
{ params }: { params: Promise<RouteLayoutParams> } { params }: { params: Promise<RouteLayoutParams> }
) { ) {
const { context } = await getStaticSiteContext(await params); const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context, { withMarkdownPages: true }); return serveLLMsTxt(context);
} }
@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveRobotsTxt } from '@/routes/robots'; import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { servePagesSitemap } from '@/routes/sitemap'; import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveRootSitemap } from '@/routes/sitemap'; import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import { serveIcon } from '@/routes/icon'; import { serveIcon } from '@/routes/icon';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -1,8 +1,8 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import type { PageIdParams } from '@/components/SitePage'; import type { PageIdParams } from '@/components/SitePage';
import { serveOGImage } from '@/routes/ogimage'; import { serveOGImage } from '@/routes/ogimage';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -1,7 +1,6 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive'; import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@/lib/context'; import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import { getDynamicCustomizationSettings } from '@/lib/customization'; import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import type { SiteAPIToken } from '@gitbook/api';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation'; import { forbidden } from 'next/navigation';
import rison from 'rison'; import rison from 'rison';
@@ -14,7 +13,7 @@ export type RouteLayoutParams = {
/** URL encoded site URL */ /** URL encoded site URL */
siteURL: string; siteURL: string;
/** URL and Rison encoded site data from resolvePublishedContentByUrl */ /** URL and Rison encoded site data from getPublishedContentByUrl */
siteData: string; siteData: string;
}; };
@@ -68,8 +67,6 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
siteURLData siteURLData
); );
context.customization = await getDynamicCustomizationSettings(context.customization);
return { return {
context, context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData), visitorAuthClaims: getVisitorAuthClaims(siteURLData),
@@ -101,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/** /**
* Get the decoded site data from the params. * Get the decoded site data from the params.
*/ */
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData { function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
const decoded = decodeURIComponent(params.siteData); const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded); return rison.decode(decoded);
} }
@@ -14,7 +14,7 @@ import {
GITBOOK_SECRET, GITBOOK_SECRET,
GITBOOK_URL, GITBOOK_URL,
GITBOOK_USER_AGENT, GITBOOK_USER_AGENT,
} from '@/lib/env'; } from '@v2/lib/env';
/** /**
* Output the public environment variables for this deployment * Output the public environment variables for this deployment
@@ -1,7 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { getLogger } from '@/lib/logger'; import { withVerifySignature } from '@v2/lib/routes';
import { withVerifySignature } from '@/lib/routes';
import { revalidateTag } from 'next/cache'; import { revalidateTag } from 'next/cache';
interface JsonBody { interface JsonBody {
@@ -13,7 +12,6 @@ interface JsonBody {
* The body should be a JSON with { tags: string[] } * The body should be a JSON with { tags: string[] }
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const logger = getLogger().subLogger('revalidate');
return withVerifySignature<JsonBody>(req, async (body) => { return withVerifySignature<JsonBody>(req, async (body) => {
if (!body.tags || !Array.isArray(body.tags)) { if (!body.tags || !Array.isArray(body.tags)) {
return NextResponse.json( return NextResponse.json(
@@ -25,7 +23,8 @@ export async function POST(req: NextRequest) {
} }
body.tags.forEach((tag) => { body.tags.forEach((tag) => {
logger.log(`Revalidating tag: ${tag}`); // biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating tag: ${tag}`);
revalidateTag(tag); revalidateTag(tag);
}); });
@@ -2,10 +2,10 @@ import {
type GitBookBaseContext, type GitBookBaseContext,
type GitBookSpaceContext, type GitBookSpaceContext,
fetchSpaceContextByIds, fetchSpaceContextByIds,
} from '@/lib/context'; } from '@v2/lib/context';
import { createDataFetcher } from '@/lib/data'; import { createDataFetcher } from '@v2/lib/data';
import { createLinker } from '@/lib/links'; import { createLinker } from '@v2/lib/links';
import { getAPITokenFromMiddleware } from '@/lib/middleware'; import { getAPITokenFromMiddleware } from '@v2/lib/middleware';
export type SpacePDFRouteParams = { export type SpacePDFRouteParams = {
spaceId: string; spaceId: string;
@@ -0,0 +1,2 @@
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
export default RootLayout;
@@ -0,0 +1,4 @@
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
export default PDFPage;
export { generateMetadata };
@@ -0,0 +1,2 @@
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
export default RootLayout;
@@ -0,0 +1,4 @@
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
export default PDFPage;
export { generateMetadata };
@@ -1,5 +1,5 @@
import { type SpacePDFRouteParams, getSpacePDFContext } from '@/app/~space/[spaceId]/pdf';
import { PDFRootLayout } from '@/components/PDF'; import { PDFRootLayout } from '@/components/PDF';
import { type SpacePDFRouteParams, getSpacePDFContext } from '@v2/app/~space/[spaceId]/pdf';
export default async function RootLayout(props: { export default async function RootLayout(props: {
params: Promise<SpacePDFRouteParams>; params: Promise<SpacePDFRouteParams>;
@@ -1,5 +1,5 @@
import { type SpacePDFRouteParams, getSpacePDFContext } from '@/app/~space/[spaceId]/pdf';
import { PDFPage, generatePDFMetadata } from '@/components/PDF'; import { PDFPage, generatePDFMetadata } from '@/components/PDF';
import { type SpacePDFRouteParams, getSpacePDFContext } from '@v2/app/~space/[spaceId]/pdf';
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -1,15 +1,8 @@
import {
type GitBookDataFetcher,
createDataFetcher,
getDataOrNull,
throwIfDataError,
} from '@/lib/data';
import { getLogger } from '@/lib/logger';
import { getSiteStructureSections } from '@/lib/sites'; import { getSiteStructureSections } from '@/lib/sites';
import type { import type {
ChangeRequest, ChangeRequest,
PublishedSiteContent, PublishedSiteContent,
Revision, RevisionPage,
RevisionPageDocument, RevisionPageDocument,
Site, Site,
SiteCustomizationSettings, SiteCustomizationSettings,
@@ -20,36 +13,11 @@ import type {
SiteStructure, SiteStructure,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import assertNever from 'assert-never'; import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials'; import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env'; import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images'; import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links'; import { type GitBookLinker, createLinker } from './links';
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
*/
export type SiteURLData = Pick<
PublishedSiteContent,
| 'organization'
| 'apiToken'
| 'site'
| 'siteSpace'
| 'space'
| 'revision'
| 'changeRequest'
| 'shareKey'
| 'siteSection'
| 'siteBasePath'
| 'basePath'
> & {
/**
* Identifier used for image resizing.
*/
imagesContextId: string;
};
/** /**
* Generic context when rendering content. * Generic context when rendering content.
@@ -85,12 +53,12 @@ export type GitBookSpaceContext = GitBookBaseContext & {
space: Space; space: Space;
changeRequest: ChangeRequest | null; changeRequest: ChangeRequest | null;
/** Revision of the space. */ /** ID of the current revision. */
revision: Revision;
/** Identifier of the revision. Could be different than `revision.id` when using computed. */
revisionId: string; revisionId: string;
/** Pages of the space. */
pages: RevisionPage[];
/** Share key of the space. */ /** Share key of the space. */
shareKey: string | undefined; shareKey: string | undefined;
}; };
@@ -137,7 +105,7 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
*/ */
export function getBaseContext(input: { export function getBaseContext(input: {
siteURL: URL | string; siteURL: URL | string;
siteURLData: SiteURLData; siteURLData: PublishedSiteContent;
urlMode: 'url' | 'url-host'; urlMode: 'url' | 'url-host';
}) { }) {
const { urlMode, siteURLData } = input; const { urlMode, siteURLData } = input;
@@ -166,12 +134,12 @@ export function getBaseContext(input: {
// Create link in the same format for links to other sites/sections. // Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => { linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL); const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`; return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
}; };
} }
const imageResizer = createImageResizer({ const imageResizer = createImageResizer({
imagesContextId: siteURLData.imagesContextId, host: siteURL.host,
// To ensure image resizing work for proxied sites, // To ensure image resizing work for proxied sites,
// we serve images from the root of the site. // we serve images from the root of the site.
linker: linker, linker: linker,
@@ -189,7 +157,7 @@ export function getBaseContext(input: {
*/ */
export async function fetchSiteContextByURLLookup( export async function fetchSiteContextByURLLookup(
baseContext: GitBookBaseContext, baseContext: GitBookBaseContext,
data: SiteURLData data: PublishedSiteContent
): Promise<GitBookSiteContext> { ): Promise<GitBookSiteContext> {
return await fetchSiteContextByIds(baseContext, { return await fetchSiteContextByIds(baseContext, {
organization: data.organization, organization: data.organization,
@@ -244,45 +212,19 @@ export async function fetchSiteContextByIds(
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection) ? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
: null; : null;
// Parse the current siteSpace and siteSpaces based on the site structure type. const siteSpace = (
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => { siteStructure.type === 'siteSpaces' && siteStructure.structure
if (siteStructure.type === 'siteSpaces') { ? siteStructure.structure
const siteSpaces = siteStructure.structure; : sections?.current.siteSpaces
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace); )?.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) {
throw new Error('Site space not found');
}
if (!siteSpace) { const siteSpaces =
throw new Error( siteStructure.type === 'siteSpaces'
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"` ? siteStructure.structure
); : (sections?.current.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 = (() => { const customization = (() => {
if (ids.siteSpace) { if (ids.siteSpace) {
@@ -291,11 +233,10 @@ export async function fetchSiteContextByIds(
return siteSpaceSettings; return siteSpaceSettings;
} }
const logger = getLogger().subLogger('fetchSiteContextByIds', {});
// We got the pointer from an API and customizations from another. // We got the pointer from an API and customizations from another.
// It's possible that the two are unsynced leading to not found customizations for the space. // It's possible that the two are unsynced leading to not found customizations for the space.
// It's better to fallback on customization of the site that displaying an error. // It's better to fallback on customization of the site that displaying an error.
logger.warn('Customization not found for site space', ids.siteSpace); console.warn('Customization not found for site space', ids.siteSpace);
} }
return customizations.site; return customizations.site;
@@ -303,9 +244,6 @@ export async function fetchSiteContextByIds(
return { return {
...spaceContext, ...spaceContext,
linker: site.urls.published
? linkerForPublishedURL(spaceContext.linker, site.urls.published)
: spaceContext.linker,
organizationId: ids.organization, organizationId: ids.organization,
site, site,
siteSpaces, siteSpaces,
@@ -339,7 +277,7 @@ export async function fetchSpaceContextByIds(
}) })
), ),
ids.changeRequest ids.changeRequest
? getDataOrNull( ? throwIfDataError(
dataFetcher.getChangeRequest({ dataFetcher.getChangeRequest({
spaceId: ids.space, spaceId: ids.space,
changeRequestId: ids.changeRequest, changeRequestId: ids.changeRequest,
@@ -348,35 +286,25 @@ export async function fetchSpaceContextByIds(
: null, : null,
]); ]);
if (ids.changeRequest && !changeRequest) { const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
// When trying to render a change request with an invalid / non-existing ID,
// we should return a 404.
notFound();
}
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision; const pages = await throwIfDataError(
dataFetcher.getRevisionPages({
const revision = await getDataOrNull(
dataFetcher.getRevision({
spaceId: ids.space, spaceId: ids.space,
revisionId, revisionId,
}), // We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
// When trying to render a revision with an invalid / non-existing ID, metadata: !!space.gitSync,
// we should handle gracefully the 404 and throw notFound. })
ids.revision ? [404] : undefined
); );
if (!revision) {
notFound();
}
return { return {
...baseContext, ...baseContext,
organizationId: space.organization, organizationId: space.organization,
space, space,
revision, pages,
revisionId,
changeRequest, changeRequest,
revisionId,
shareKey: ids.shareKey, shareKey: ids.shareKey,
}; };
} }
@@ -409,7 +337,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) { function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false }); const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
const section = parseCurrentSection(structure, siteSectionId); const section = parseCurrentSection(structure, siteSectionId);
assert(section, `couldn't find section "${siteSectionId}" in site structure`); assert(section, 'A section must be defined when there are multiple sections');
return { list: sectionsAndGroups, current: section } satisfies SiteSections; return { list: sectionsAndGroups, current: section } satisfies SiteSections;
} }
+647
View File
@@ -0,0 +1,647 @@
import { trace } from '@/lib/tracing';
import {
type ComputedContentSource,
GitBookAPI,
type GitBookAPIServiceBinding,
type RenderIntegrationUI,
} from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { wrapDataFetcherError } from './errors';
import { memoize } from './memoize';
import type { GitBookDataFetcher } from './types';
interface DataFetcherInput {
/**
* API token.
*/
apiToken: string | null;
}
/**
* Create a data fetcher using an API token.
* The data are being cached by Next.js built-in cache.
*/
export function createDataFetcher(
input: DataFetcherInput = { apiToken: null }
): GitBookDataFetcher {
return {
async api() {
return apiClient(input);
},
withToken({ apiToken }) {
return createDataFetcher({
apiToken,
});
},
//
// API that are tied to the token
//
getPublishedContentSite(params) {
return trace('getPublishedContentSite', () =>
getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
})
);
},
getSiteRedirectBySource(params) {
return trace('getSiteRedirectBySource', () =>
getSiteRedirectBySource(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
source: params.source,
})
);
},
getRevision(params) {
return trace('getRevision', () =>
getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
})
);
},
getRevisionPages(params) {
return trace('getRevisionPages', () =>
getRevisionPages(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
})
);
},
getRevisionFile(params) {
return trace('getRevisionFile', () =>
getRevisionFile(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
fileId: params.fileId,
})
);
},
getRevisionPageByPath(params) {
return trace('getRevisionPageByPath', () =>
getRevisionPageByPath(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
})
);
},
getReusableContent(params) {
return trace('getReusableContent', () =>
getReusableContent(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
reusableContentId: params.reusableContentId,
})
);
},
getLatestOpenAPISpecVersionContent(params) {
return trace('getLatestOpenAPISpecVersionContent', () =>
getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
slug: params.slug,
})
);
},
getSpace(params) {
return trace('getSpace', () =>
getSpace(input, {
spaceId: params.spaceId,
shareKey: params.shareKey,
})
);
},
getChangeRequest(params) {
return trace('getChangeRequest', () =>
getChangeRequest(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
})
);
},
getDocument(params) {
return trace('getDocument', () =>
getDocument(input, {
spaceId: params.spaceId,
documentId: params.documentId,
})
);
},
getComputedDocument(params) {
return trace('getComputedDocument', () =>
getComputedDocument(input, {
organizationId: params.organizationId,
spaceId: params.spaceId,
source: params.source,
seed: params.seed,
})
);
},
getEmbedByUrl(params) {
return trace('getEmbedByUrl', () =>
getEmbedByUrl(input, {
url: params.url,
spaceId: params.spaceId,
})
);
},
searchSiteContent(params) {
return trace('searchSiteContent', () => searchSiteContent(input, params));
},
renderIntegrationUi(params) {
return trace('renderIntegrationUi', () =>
renderIntegrationUi(input, {
integrationName: params.integrationName,
request: params.request,
})
);
},
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) {
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
},
};
}
const getUserById = memoize(async function getUserById(
input: DataFetcherInput,
params: { userId: string }
) {
'use cache';
return trace('getUserById.uncached', () => {
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.users.getUserById(params.userId);
return res.data;
});
});
});
const getSpace = memoize(async function getSpace(
input: DataFetcherInput,
params: {
spaceId: string;
shareKey: string | undefined;
}
) {
'use cache';
return trace('getSpace.uncached', () => {
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
});
return res.data;
});
});
});
const getChangeRequest = memoize(async function getChangeRequest(
input: DataFetcherInput,
params: {
spaceId: string;
changeRequestId: string;
}
) {
'use cache';
return trace('getChangeRequest.uncached', () => {
cacheLife('minutes');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId
);
cacheTag(
getCacheTag({
tag: 'change-request',
space: params.spaceId,
changeRequest: res.data.id,
})
);
return res.data;
});
});
});
const getRevision = memoize(async function getRevision(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}
) {
'use cache';
return trace('getRevision.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
});
return res.data;
});
});
});
const getRevisionPages = memoize(async function getRevisionPages(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}
) {
'use cache';
return trace('getRevisionPages.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
}
);
return res.data.pages;
});
});
});
const getRevisionFile = memoize(async function getRevisionFile(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
fileId: string;
}
) {
'use cache';
return trace('getRevisionFile.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
{}
);
return res.data;
});
});
});
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
path: string;
}
) {
'use cache';
return trace('getRevisionPageByPath.uncached', () => {
cacheLife('max');
const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
{}
);
return res.data;
});
});
});
const getDocument = memoize(async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
documentId: string;
}
) {
'use cache';
return trace('getDocument.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
return res.data;
});
});
});
const getComputedDocument = memoize(async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
organizationId: string;
source: ComputedContentSource;
seed: string;
}
) {
'use cache';
return trace('getComputedDocument.uncached', () => {
cacheLife('days');
cacheTag(
...getComputedContentSourceCacheTags(
{
spaceId: params.spaceId,
organizationId: params.organizationId,
},
params.source
)
);
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getComputedDocument(params.spaceId, {
source: params.source,
seed: params.seed,
});
return res.data;
});
});
});
const getReusableContent = memoize(async function getReusableContent(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}
) {
'use cache';
return trace('getReusableContent.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId
);
return res.data;
});
});
});
const getLatestOpenAPISpecVersionContent = memoize(
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
}
) {
'use cache';
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
cacheTag(
getCacheTag({
tag: 'openapi',
organization: params.organizationId,
openAPISpec: params.slug,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug
);
return res.data;
});
});
}
);
const getPublishedContentSite = memoize(async function getPublishedContentSite(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}
) {
'use cache';
return trace('getPublishedContentSite.uncached', () => {
cacheLife('days');
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
return trace('getPublishedContentSite', () => {
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
}
);
return res.data;
});
});
});
});
const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
}
) {
'use cache';
return trace('getSiteRedirectBySource.uncached', () => {
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
}
);
return res.data;
});
});
});
const getEmbedByUrl = memoize(async function getEmbedByUrl(
input: DataFetcherInput,
params: {
url: string;
spaceId: string;
}
) {
'use cache';
return trace('getEmbedByUrl.uncached', () => {
cacheLife('weeks');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data;
});
});
});
const searchSiteContent = memoize(async function searchSiteContent(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) {
'use cache';
return trace('searchSiteContent.uncached', () => {
const { organizationId, siteId, query, scope } = params;
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
query,
...scope,
});
return res.data.items;
});
});
});
const renderIntegrationUi = memoize(async function renderIntegrationUi(
input: DataFetcherInput,
params: {
integrationName: string;
request: RenderIntegrationUI;
}
) {
'use cache';
return trace('renderIntegrationUi.uncached', () => {
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost(
params.integrationName,
params.request
);
return res.data;
});
});
});
let loggedServiceBinding = false;
/**
* Create a new API client.
*/
export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
const { apiToken } = input;
let serviceBinding: GitBookAPIServiceBinding | undefined;
try {
// HACK: This is a workaround to avoid webpack trying to bundle this cloudflare only module
// @ts-ignore
const { env } = await import(
/* webpackIgnore: true */ `${'__cloudflare:workers'.replaceAll('_', '')}`
);
serviceBinding = env.GITBOOK_API;
if (!loggedServiceBinding) {
loggedServiceBinding = true;
if (serviceBinding) {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`using service binding for the API (${GITBOOK_API_URL})`);
} else {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
}
}
} catch (error) {
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) {
throw error;
}
}
const api = new GitBookAPI({
authToken: apiToken || GITBOOK_API_TOKEN || undefined,
endpoint: GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
serviceBinding,
});
return api;
}
+115
View File
@@ -0,0 +1,115 @@
import { GitBookAPIError } from '@gitbook/api';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
export class DataFetcherError extends Error {
constructor(
message: string,
public readonly code: number
) {
super(message);
}
}
/**
* Throw an error if the response contains an error.
*/
export function throwIfDataError<T>(response: DataFetcherResponse<T>): T;
export function throwIfDataError<T>(response: Promise<DataFetcherResponse<T>>): Promise<T>;
export function throwIfDataError<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>
): T | Promise<T> {
if (response instanceof Promise) {
return response.then((result) => throwIfDataError(result));
}
if (response.error) {
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
}
/**
* Get the data from the response or null if there is an "Not found" error.
*/
export function getDataOrNull<T>(
response: DataFetcherResponse<T>,
ignoreErrors?: number[]
): T | null;
export function getDataOrNull<T>(
response: Promise<DataFetcherResponse<T>>,
ignoreErrors?: number[]
): Promise<T | null>;
export function getDataOrNull<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>,
ignoreErrors: number[] = [404]
): T | null | Promise<T | null> {
if (response instanceof Promise) {
return response.then((result) => getDataOrNull(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) return null;
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
}
/**
* Ignore error for an API or data call.
*/
export async function ignoreDataThrownError<T>(promise: Promise<T>): Promise<T | null> {
try {
return await promise;
} catch (error) {
getExposableError(error as Error);
return null;
}
}
/**
* Ignore all errors for an API or data call.
*/
export async function ignoreAllThrownError<T>(promise: Promise<T>): Promise<T | null> {
try {
return await promise;
} catch (error) {
console.warn('Ignoring error', error);
return null;
}
}
/**
* Wrap an async execution to handle errors and return a DataFetcherResponse.
*/
export async function wrapDataFetcherError<T>(
fn: () => Promise<T>
): Promise<DataFetcherResponse<T>> {
try {
return { data: await fn() };
} catch (error) {
return {
error: getExposableError(error as Error),
};
}
}
/**
* Get a data fetcher exposable error from a JS error.
*/
export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
return {
code: error.code,
message: error.errorMessage,
};
}
if (error instanceof DataFetcherError) {
return {
code: error.code,
message: error.message,
};
}
throw error;
}
@@ -1,8 +1,6 @@
export * from './api'; export * from './api';
export * from './types'; export * from './types';
export * from './pages';
export * from './urls'; export * from './urls';
export * from './errors'; export * from './errors';
export * from './lookup'; export * from './lookup';
export * from './visitor';
export * from './pages';
export * from './revisions';
@@ -1,46 +1,45 @@
import { race, tryCatch } from '@/lib/async'; import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths'; import { joinPath } from '@/lib/paths';
import { trace } from '@/lib/tracing'; import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api'; import type { PublishedSiteContentLookup } from '@gitbook/api';
import { apiClient } from './api'; import { apiClient } from './api';
import { getExposableError } from './errors'; import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types'; import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls'; import { getURLLookupAlternatives, stripURLSearch } from './urls';
interface LookupPublishedContentByUrlInput {
url: string;
redirectOnError: boolean;
apiToken: string | null;
visitorPayload: SiteVisitorPayload;
}
/** /**
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint. * 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. * To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/ */
export async function lookupPublishedContentByUrl( export async function getPublishedContentByURL(input: {
input: LookupPublishedContentByUrlInput url: string;
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> { visitorAuthToken: string | null;
redirectOnError: boolean;
apiToken: string | null;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url); const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL); const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url); const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => { const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = apiClient({ apiToken: input.apiToken }); const api = await apiClient({ apiToken: input.apiToken });
const callResult = await trace( const callResult = await trace(
{ {
operation: 'resolvePublishedContentByUrl', operation: 'getPublishedContentByURL',
name: alternative.url, name: alternative.url,
}, },
() => () =>
tryCatch( tryCatch(
api.urls.resolvePublishedContentByUrl( api.urls.getPublishedContentByUrl(
{ {
url: alternative.url, url: alternative.url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}), visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError, redirectOnError: input.redirectOnError,
}, },
{ signal } {
signal,
}
) )
) )
); );
@@ -101,7 +100,6 @@ export async function lookupPublishedContentByUrl(
const siteResult: PublishedSiteContentLookup = { const siteResult: PublishedSiteContentLookup = {
...data, ...data,
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
basePath: joinPath(data.basePath, lookup.basePath ?? ''), basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath), pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}), ...(changeRequest ? { changeRequest } : {}),
@@ -0,0 +1,39 @@
import { describe, expect, it, mock } from 'bun:test';
import { memoize } 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());
});
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));
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 () => {
const fn = mock(async () => Math.random());
const memoized = memoize(fn);
const promise1 = memoized();
const promise2 = memoized();
expect(await promise1).toBe(await promise2);
expect(fn.mock.calls.length).toBe(1);
});
});

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