Compare commits

..

1 Commits

Author SHA1 Message Date
Greg Bergé 5fdb854400 Add loading indicator 2025-03-26 21:48:33 +01:00
308 changed files with 4155 additions and 11355 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
+2 -2
View File
@@ -63,9 +63,9 @@ To start your local version of GitBook, run the command `bun dev`.
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`. When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/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
@@ -19,12 +19,6 @@ 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"
@@ -55,13 +49,10 @@ runs:
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker - name: Build worker
run: bun run turbo build:v2:cloudflare run: bun run turbo build:v2:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
shell: bash shell: bash
- id: deploy - id: deploy
name: Deploy to Cloudflare name: Deploy to Cloudflare
@@ -70,9 +61,9 @@ runs:
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: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
- name: Outputs - name: Outputs
shell: bash shell: bash
env: env:
@@ -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
+10 -63
View File
@@ -40,7 +40,6 @@ jobs:
run: bun run turbo gitbook#build:cloudflare run: bun run turbo gitbook#build:cloudflare
env: env:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }} NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
GITBOOK_RUNTIME: cloudflare
- id: deploy - id: deploy
name: Deploy to Cloudflare name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0 uses: cloudflare/wrangler-action@v3.14.0
@@ -82,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
@@ -96,19 +95,9 @@ 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)
@@ -134,14 +123,14 @@ jobs:
body: | body: |
Summary of the deployments: Summary of the deployments:
### Version 1 ### Version 1 (production)
| Version | URL | Status | | Version | URL | Status |
| --- | --- | --- | | --- | --- | --- |
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} | | Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} | | PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
### Version 2 ### Version 2 (experimental)
| Version | URL | Status | | Version | URL | Status |
| --- | --- | --- | | --- | --- | --- |
@@ -150,10 +139,10 @@ jobs:
### Test content ### Test content
| Site | `v1` | `2v` | `2c` | | Site | v1 | v2 |
| --- | --- | --- | --- | | --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) | | GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ 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: visual-testing-v1:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -195,32 +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:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v1: visual-testing-customers-v1:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing Customers v1 name: Visual Testing Customers v1
needs: deploy-v1-cloudflare needs: deploy-v1-cloudflare
timeout-minutes: 8 timeout-minutes: 6
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -240,7 +208,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Visual Testing Customers v2 name: Visual Testing Customers v2
needs: deploy-v2-vercel needs: deploy-v2-vercel
timeout-minutes: 8 timeout-minutes: 6
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -257,27 +225,6 @@ jobs:
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/ SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2' ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v1: pagespeed-testing-v1:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: PageSpeed Testing v1 name: PageSpeed Testing v1
+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 }}"
-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",
+4 -4
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://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">
@@ -59,15 +59,15 @@ bun install
4. Start your local development server. 4. Start your local development server.
``` ```
bun dev:v2 bun dev
``` ```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`. 5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples: examples:
- http://localhost:3000/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.
+1 -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/cache-do/worker-configuration.d.ts"
] ]
}, },
"formatter": { "formatter": {
+218 -365
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -4,15 +4,15 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12", "@changesets/cli": "^2.27.12",
"turbo": "^2.5.0", "turbo": "^2.4.4",
"vercel": "^39.3.0" "vercel": "^39.3.0"
}, },
"packageManager": "bun@1.2.11", "packageManager": "bun@1.2.5",
"overrides": { "overrides": {
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@gitbook/api": "^0.115.0", "react": "18.3.1",
"react": "^19.0.0", "react-dom": "18.3.1",
"react-dom": "^19.0.0" "@gitbook/api": "0.102.0"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
+2 -2
View File
@@ -21,10 +21,10 @@
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.3", "typescript": "^5.5.3",
"wrangler": "^4.10.0" "wrangler": "^3.112.0"
}, },
"scripts": { "scripts": {
"generate": "wrangler types", "generate": "wrangler types --experimental-include-runtime",
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "tsc -w", "dev": "tsc -w",
+1 -1
View File
@@ -14,7 +14,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"incremental": true, "incremental": true,
"types": ["./worker-configuration.d.ts"] "types": ["./.wrangler/types/runtime.d.ts"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
-8
View File
@@ -1,8 +0,0 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["worker-configuration.d.ts"]
}
}
}
-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": "^0.115.0", "@gitbook/api": "*",
"assert-never": "^1.2.1" "assert-never": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
+4 -27
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.
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
tags.push( tags.push(
getCacheTag({ getCacheTag({
tag: 'integration', tag: 'integration',
integration, integration: source.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
-1
View File
@@ -6,7 +6,6 @@
# cloudflare # cloudflare
.open-next .open-next
.wrangler
# Symbolic links # Symbolic links
public public
-40
View File
@@ -1,45 +1,5 @@
# gitbook-v2 # gitbook-v2
## 0.3.0
### Minor Changes
- 3119066: Add support for reusable content across spaces.
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
### Patch Changes
- 1c8d9fe: keep data cache in OpenNext between deployment
- 778624a: Only resize images with supported extensions.
- e6ddc0f: Fix URL in sitemap
- 5e975ab: Fix code highlighting for HTTP
- e15757d: Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
- 634e0b4: Improve error messages around undefined site sections.
- 97b7c79: Increase logging around caching behaviour causing page crashes.
- 3f29206: Update the regex for validating site redirect
- dd043df: Revert investigation work around URL caches.
## 0.2.5
### Patch Changes
- Updated dependencies [77397ca]
- @gitbook/cache-tags@0.3.1
## 0.2.4
### Patch Changes
- 4234289: Fix incoming URL for requests that were proxied
- Updated dependencies [116575c]
- @gitbook/cache-tags@0.3.0
## 0.2.3
### Patch Changes
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
## 0.2.2 ## 0.2.2
### Patch Changes ### Patch Changes
+2 -20
View File
@@ -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;
+6 -23
View File
@@ -1,27 +1,10 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare'; import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue'; import d1TagCache from '@opennextjs/cloudflare/d1-tag-cache';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache'; import kvIncrementalCache from '@opennextjs/cloudflare/kv-cache';
import { import memoryQueue from '@opennextjs/cloudflare/memory-queue';
softTagFilter,
withFilter,
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
export default defineCloudflareConfig({ export default defineCloudflareConfig({
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default), incrementalCache: kvIncrementalCache,
tagCache: withFilter({ queue: memoryQueue,
tagCache: doShardedTagCache({ tagCache: d1TagCache,
baseShardSize: 12,
regionalCache: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
},
}),
// We don't use `revalidatePath`, so we filter out soft tags
filterFn: softTagFilter,
}),
queue: doQueue,
// Performance improvements as we don't use PPR
enableCacheInterception: true,
}); });
@@ -1,186 +0,0 @@
import { createHash } from 'node:crypto';
import { trace } from '@/lib/tracing';
import type {
CacheEntryType,
CacheValue,
IncrementalCache,
WithLastModified,
} from '@opennextjs/aws/types/overrides.js';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
export const DEFAULT_PREFIX = 'incremental-cache';
export type KeyOptions = {
cacheType?: CacheEntryType;
};
/**
*
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
* the cache operations. It also integrates both R2 and Cache API in a single class.
* Having our own, will allow us to customize it in the future if needed.
*/
class GitbookIncrementalCache implements IncrementalCache {
name = 'GitbookIncrementalCache';
protected localCache: Cache | undefined;
async get<CacheType extends CacheEntryType = 'cache'>(
key: string,
cacheType?: CacheType
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
const cacheKey = this.getR2Key(key, cacheType);
return trace(
{
operation: 'openNextIncrementalCacheGet',
name: cacheKey,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
// Check local cache first if available
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
if (localCacheEntry) {
span.setAttribute('cacheHit', 'local');
return localCacheEntry.json();
}
const r2Object = await r2.get(cacheKey);
if (!r2Object) return null;
span.setAttribute('cacheHit', 'r2');
return {
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
};
} catch (e) {
console.error('Failed to get from cache', e);
return null;
}
}
);
}
async set<CacheType extends CacheEntryType = 'cache'>(
key: string,
value: CacheValue<CacheType>,
cacheType?: CacheType
): Promise<void> {
const cacheKey = this.getR2Key(key, cacheType);
return trace(
{
operation: 'openNextIncrementalCacheSet',
name: cacheKey,
},
async (span) => {
span.setAttribute('cacheType', cacheType ?? 'cache');
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
await r2.put(cacheKey, JSON.stringify(value));
//TODO: Check if there is any places where we don't have tags
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
const tags = this.getTagsFromCacheEntry(value) ?? [
`build_id/${process.env.NEXT_BUILD_ID}`,
];
// We consider R2 as the source of truth, so we update the local cache
// only after a successful R2 write
await localCache.put(
this.getCacheUrlKey(cacheKey),
new Response(
JSON.stringify({
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
}),
{
headers: {
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
// In theory we should always get the `revalidate` value
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
'cache-tag': tags.join(','),
},
}
)
);
} catch (e) {
console.error('Failed to set to cache', e);
}
}
);
}
async delete(key: string): Promise<void> {
const cacheKey = this.getR2Key(key);
return trace(
{
operation: 'openNextIncrementalCacheDelete',
name: cacheKey,
},
async () => {
const r2 = getCloudflareContext().env[BINDING_NAME];
const localCache = await this.getCacheInstance();
if (!r2) throw new Error('No R2 bucket');
try {
await r2.delete(cacheKey);
// Here again R2 is the source of truth, so we delete from local cache first
await localCache.delete(this.getCacheUrlKey(cacheKey));
} catch (e) {
console.error('Failed to delete from cache', e);
}
}
);
}
async getCacheInstance(): Promise<Cache> {
if (this.localCache) return this.localCache;
this.localCache = await caches.open('incremental-cache');
return this.localCache;
}
// Utility function to generate keys for R2/Cache API
getR2Key(key: string, cacheType: CacheEntryType = 'cache'): string {
const hash = createHash('sha256').update(key).digest('hex');
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
/\/+/g,
'/'
);
}
getCacheUrlKey(cacheKey: string): string {
return `http://cache.local/${cacheKey}`;
}
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
entry: CacheValue<CacheType>
): string[] | undefined {
if ('tags' in entry && entry.tags) {
return entry.tags;
}
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
const rawTags = entry.meta.headers['x-next-cache-tags'];
if (typeof rawTags === 'string') {
return rawTags.split(',');
}
}
if ('value' in entry) {
return entry.tags;
}
}
}
export default new GitbookIncrementalCache();
+12 -12
View File
@@ -1,23 +1,23 @@
{ {
"name": "gitbook-v2", "name": "gitbook-v2",
"version": "0.3.0", "version": "0.2.2",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@gitbook/api": "^0.115.0", "next": "^15.2.3",
"@gitbook/cache-tags": "workspace:*",
"@opennextjs/cloudflare": "1.0.3",
"@sindresorhus/fnv1a": "^3.1.0",
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"rison": "^0.1.1", "@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"warn-once": "^0.1.1" "warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0",
"p-memoize": "^7.1.1"
}, },
"devDependencies": { "devDependencies": {
"gitbook": "*", "gitbook": "*",
"@opennextjs/cloudflare": "^0.5.10",
"@types/rison": "^0.0.9", "@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"postcss": "^8" "postcss": "^8"
@@ -28,8 +28,8 @@
"build": "next build", "build": "next build",
"build:v2": "next build", "build:v2": "next build",
"start": "next start", "start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare build", "build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview", "dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test", "unit": "bun test",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
} }
@@ -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);
}
@@ -3,9 +3,10 @@ import {
generateSitePageMetadata, generateSitePageMetadata,
generateSitePageViewport, generateSitePageViewport,
} from '@/components/SitePage'; } from '@/components/SitePage';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils'; import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { unstable_cacheTag as cacheTag } from 'next/cache';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -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 }} />;
} }
@@ -4,8 +4,10 @@ import {
generateSiteLayoutMetadata, generateSiteLayoutMetadata,
generateSiteLayoutViewport, generateSiteLayoutViewport,
} from '@/components/SiteLayout'; } from '@/components/SiteLayout';
import { getCacheTag } from '@gitbook/cache-tags';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils'; import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env'; import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { unstable_cacheTag as cacheTag } from 'next/cache';
interface SiteStaticLayoutProps { interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>; params: Promise<RouteLayoutParams>;
@@ -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
@@ -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,11 +0,0 @@
import { servePageMarkdown } from '@/routes/markdownPage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageMarkdown(context, pathname);
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive'; import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import type { SiteAPIToken } from '@gitbook/api'; import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context'; import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation'; import { forbidden } from 'next/navigation';
import rison from 'rison'; import rison from 'rison';
@@ -98,7 +98,7 @@ function getModeFromParams(mode: string): RouteParamMode {
/** /**
* Get the decoded site data from the params. * Get the decoded site data from the params.
*/ */
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData { function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
const decoded = decodeURIComponent(params.siteData); const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded); return rison.decode(decoded);
} }
+19 -89
View File
@@ -13,43 +13,12 @@ import type {
SiteStructure, SiteStructure,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import { import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
type GitBookDataFetcher,
createDataFetcher,
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import assertNever from 'assert-never';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials'; import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env'; import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images'; import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker } from './links'; import { type GitBookLinker, createLinker } from './links';
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
*/
export type SiteURLData = Pick<
PublishedSiteContent,
| 'organization'
| 'apiToken'
| 'site'
| 'siteSpace'
| 'space'
| 'revision'
| 'changeRequest'
| 'shareKey'
| 'siteSection'
| 'siteBasePath'
| 'basePath'
> & {
/**
* Identifier used for image resizing.
*/
imagesContextId: string;
};
/** /**
* Generic context when rendering content. * Generic context when rendering content.
*/ */
@@ -136,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;
@@ -170,7 +139,7 @@ export function getBaseContext(input: {
} }
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,
@@ -188,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,
@@ -243,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) { if (!siteSpace) {
throw new Error( throw new Error('Site space not found');
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
);
} }
return { siteSpaces, siteSpace }; const siteSpaces =
} siteStructure.type === 'siteSpaces'
? siteStructure.structure
if (siteStructure.type === 'sections') { : (sections?.current.siteSpaces ?? []);
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) {
@@ -334,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,
@@ -343,30 +286,17 @@ 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(
const pages = await getDataOrNull(
dataFetcher.getRevisionPages({ dataFetcher.getRevisionPages({
spaceId: ids.space, spaceId: ids.space,
revisionId, revisionId,
// We only care about the Git metadata when the Git sync is enabled, // We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it // otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync, metadata: !!space.gitSync,
}), })
// When trying to render a revision with an invalid / non-existing ID,
// we should handle gracefully the 404 and throw notFound.
ids.revision ? [404] : undefined
); );
if (!pages) {
notFound();
}
return { return {
...baseContext, ...baseContext,
@@ -407,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;
} }
+239 -465
View File
@@ -3,15 +3,13 @@ import {
type ComputedContentSource, type ComputedContentSource,
GitBookAPI, GitBookAPI,
type GitBookAPIServiceBinding, type GitBookAPIServiceBinding,
type HttpResponse,
type RenderIntegrationUI, type RenderIntegrationUI,
} from '@gitbook/api'; } from '@gitbook/api';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags'; import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env'; import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'; import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { getCloudflareContext, getCloudflareRequestGlobal } from './cloudflare'; import { wrapDataFetcherError } from './errors';
import { DataFetcherError, wrapDataFetcherError } from './errors'; import { memoize } from './memoize';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
import type { GitBookDataFetcher } from './types'; import type { GitBookDataFetcher } from './types';
interface DataFetcherInput { interface DataFetcherInput {
@@ -21,15 +19,6 @@ interface DataFetcherInput {
apiToken: string | null; apiToken: string | null;
} }
/**
* Options to pass to the `fetch` call to disable the Next data-cache when wrapped in `use cache`.
*/
export const noCacheFetchOptions: Partial<RequestInit> = {
next: {
revalidate: 0,
},
};
/** /**
* Create a data fetcher using an API token. * Create a data fetcher using an API token.
* The data are being cached by Next.js built-in cache. * The data are being cached by Next.js built-in cache.
@@ -106,15 +95,6 @@ export function createDataFetcher(
}) })
); );
}, },
getRevisionPageMarkdown(params) {
return trace('getRevisionPageMarkdown', () =>
getRevisionPageMarkdown(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
pageId: params.pageId,
})
);
},
getReusableContent(params) { getReusableContent(params) {
return trace('getReusableContent', () => return trace('getReusableContent', () =>
getReusableContent(input, { getReusableContent(input, {
@@ -186,46 +166,44 @@ export function createDataFetcher(
}) })
); );
}, },
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) { getUserById(userId) {
return trace('getUserById', () => getUserById(input, { userId })); return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
},
streamAIResponse(params) {
return streamAIResponse(input, params);
}, },
}; };
} }
const getUserById = withCacheKey( const getUserById = memoize(async function getUserById(
withoutConcurrentExecution( input: DataFetcherInput,
getCloudflareRequestGlobal, params: { userId: string }
async (_, input: DataFetcherInput, params: { userId: string }) => { ) {
'use cache'; 'use cache';
return trace(`getUserById(${params.userId})`, async () => {
return wrapDataFetcherError(async () => { return trace('getUserById.uncached', () => {
const api = apiClient(input);
const res = await api.users.getUserById(params.userId, {
...noCacheFetchOptions,
});
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days'); cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.users.getUserById(params.userId);
return res.data; return res.data;
}); });
}); });
} });
)
);
const getSpace = withCacheKey( const getSpace = memoize(async function getSpace(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; shareKey: string | undefined } params: {
) => { spaceId: string;
shareKey: string | undefined;
}
) {
'use cache'; 'use cache';
return trace('getSpace.uncached', () => {
cacheLife('days');
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'space', tag: 'space',
@@ -233,265 +211,171 @@ const getSpace = withCacheKey(
}) })
); );
return trace(`getSpace(${params.spaceId}, ${params.shareKey})`, async () => {
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.spaces.getSpaceById( const res = await api.spaces.getSpaceById(params.spaceId, {
params.spaceId,
{
shareKey: params.shareKey, shareKey: params.shareKey,
}, });
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data; return res.data;
}); });
}); });
} });
)
);
const getChangeRequest = withCacheKey( const getChangeRequest = memoize(async function getChangeRequest(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; changeRequestId: string } params: {
) => { spaceId: string;
changeRequestId: string;
}
) {
'use cache'; '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( cacheTag(
getCacheTag({ getCacheTag({
tag: 'change-request', tag: 'change-request',
space: params.spaceId, space: params.spaceId,
changeRequest: params.changeRequestId, changeRequest: res.data.id,
}) })
); );
return trace(
`getChangeRequest(${params.spaceId}, ${params.changeRequestId})`,
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId,
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('minutes');
return res.data; return res.data;
}); });
} });
); });
}
)
);
const getRevision = withCacheKey( const getRevision = memoize(async function getRevision(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; revisionId: string; metadata: boolean } params: {
) => { spaceId: string;
'use cache'; revisionId: string;
return trace(`getRevision(${params.spaceId}, ${params.revisionId})`, async () => { metadata: boolean;
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
{
...noCacheFetchOptions,
} }
); ) {
cacheTag(...getCacheTagsFromResponse(res)); 'use cache';
return trace('getRevision.uncached', () => {
cacheLife('max'); 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; return res.data;
}); });
}); });
} });
)
);
const getRevisionPages = withCacheKey( const getRevisionPages = memoize(async function getRevisionPages(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; revisionId: string; metadata: boolean } params: {
) => { spaceId: string;
revisionId: string;
metadata: boolean;
}
) {
'use cache'; 'use cache';
return trace(`getRevisionPages(${params.spaceId}, ${params.revisionId})`, async () => {
return trace('getRevisionPages.uncached', () => {
cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.spaces.listPagesInRevisionById( const res = await api.spaces.listPagesInRevisionById(
params.spaceId, params.spaceId,
params.revisionId, params.revisionId,
{ {
metadata: params.metadata, metadata: params.metadata,
},
{
...noCacheFetchOptions,
} }
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data.pages; return res.data.pages;
}); });
}); });
} });
)
);
const getRevisionFile = withCacheKey( const getRevisionFile = memoize(async function getRevisionFile(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; revisionId: string; fileId: string } params: {
) => { spaceId: string;
revisionId: string;
fileId: string;
}
) {
'use cache'; 'use cache';
return trace(
`getRevisionFile(${params.spaceId}, ${params.revisionId}, ${params.fileId})`, return trace('getRevisionFile.uncached', () => {
async () => { cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.spaces.getFileInRevisionById( const res = await api.spaces.getFileInRevisionById(
params.spaceId, params.spaceId,
params.revisionId, params.revisionId,
params.fileId, params.fileId,
{}, {}
{
...noCacheFetchOptions,
}
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data; return res.data;
}); });
} });
); });
}
)
);
const getRevisionPageMarkdown = withCacheKey( const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; revisionId: string; pageId: string } params: {
) => { spaceId: string;
'use cache'; revisionId: string;
return trace( path: string;
`getRevisionPageMarkdown(${params.spaceId}, ${params.revisionId}, ${params.pageId})`,
async () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getPageInRevisionById(
params.spaceId,
params.revisionId,
params.pageId,
{
format: 'markdown',
},
{
...noCacheFetchOptions,
} }
); ) {
'use cache';
cacheTag(...getCacheTagsFromResponse(res)); return trace('getRevisionPageByPath.uncached', () => {
cacheLife('max'); cacheLife('max');
if (!('markdown' in res.data)) {
throw new DataFetcherError('Page is not a document', 404);
}
return res.data.markdown;
});
}
);
}
)
);
const getRevisionPageByPath = withCacheKey(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput,
params: { spaceId: string; revisionId: string; path: string }
) => {
'use cache';
return trace(
`getRevisionPageByPath(${params.spaceId}, ${params.revisionId}, ${params.path})`,
async () => {
const encodedPath = encodeURIComponent(params.path); const encodedPath = encodeURIComponent(params.path);
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.spaces.getPageInRevisionByPath( const res = await api.spaces.getPageInRevisionByPath(
params.spaceId, params.spaceId,
params.revisionId, params.revisionId,
encodedPath, encodedPath,
{}, {}
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data;
});
}
);
}
)
); );
const getDocument = withCacheKey( return res.data;
withoutConcurrentExecution( });
getCloudflareRequestGlobal, });
async (_, input: DataFetcherInput, params: { spaceId: string; documentId: string }) => { });
const getDocument = memoize(async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
documentId: string;
}
) {
'use cache'; 'use cache';
return trace(`getDocument(${params.spaceId}, ${params.documentId})`, async () => {
return wrapDataFetcherError(async () => { return trace('getDocument.uncached', () => {
const api = apiClient(input);
const res = await api.spaces.getDocumentById(
params.spaceId,
params.documentId,
{},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max'); cacheLife('max');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
return res.data; return res.data;
}); });
}); });
} });
)
);
const getComputedDocument = withCacheKey( const getComputedDocument = memoize(async function getComputedDocument(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { params: {
spaceId: string; spaceId: string;
@@ -499,8 +383,12 @@ const getComputedDocument = withCacheKey(
source: ComputedContentSource; source: ComputedContentSource;
seed: string; seed: string;
} }
) => { ) {
'use cache'; 'use cache';
return trace('getComputedDocument.uncached', () => {
cacheLife('days');
cacheTag( cacheTag(
...getComputedContentSourceCacheTags( ...getComputedContentSourceCacheTags(
{ {
@@ -511,70 +399,53 @@ const getComputedDocument = withCacheKey(
) )
); );
return trace(
`getComputedDocument(${params.spaceId}, ${params.organizationId}, ${params.source.type}, ${params.seed})`,
async () => {
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.spaces.getComputedDocument( const res = await api.spaces.getComputedDocument(params.spaceId, {
params.spaceId,
{
source: params.source, source: params.source,
seed: params.seed, seed: params.seed,
}, });
{},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data; return res.data;
}); });
} });
); });
}
)
);
const getReusableContent = withCacheKey( const getReusableContent = memoize(async function getReusableContent(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { spaceId: string; revisionId: string; reusableContentId: string } params: {
) => { spaceId: string;
revisionId: string;
reusableContentId: string;
}
) {
'use cache'; 'use cache';
return trace(
`getReusableContent(${params.spaceId}, ${params.revisionId}, ${params.reusableContentId})`, return trace('getReusableContent.uncached', () => {
async () => { cacheLife('max');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.spaces.getReusableContentInRevisionById( const res = await api.spaces.getReusableContentInRevisionById(
params.spaceId, params.spaceId,
params.revisionId, params.revisionId,
params.reusableContentId, params.reusableContentId
{},
{
...noCacheFetchOptions,
}
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data; return res.data;
}); });
} });
); });
}
)
);
const getLatestOpenAPISpecVersionContent = withCacheKey( const getLatestOpenAPISpecVersionContent = memoize(
withoutConcurrentExecution( async function getLatestOpenAPISpecVersionContent(
getCloudflareRequestGlobal, input: DataFetcherInput,
async (_, input: DataFetcherInput, params: { organizationId: string; slug: string }) => { params: {
organizationId: string;
slug: string;
}
) {
'use cache'; 'use cache';
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'openapi', tag: 'openapi',
@@ -582,38 +453,32 @@ const getLatestOpenAPISpecVersionContent = withCacheKey(
openAPISpec: params.slug, openAPISpec: params.slug,
}) })
); );
cacheLife('days');
return trace(
`getLatestOpenAPISpecVersionContent(${params.organizationId}, ${params.slug})`,
async () => {
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.orgs.getLatestOpenApiSpecVersionContent( const res = await api.orgs.getLatestOpenApiSpecVersionContent(
params.organizationId, params.organizationId,
params.slug, params.slug
{
...noCacheFetchOptions,
}
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('max');
return res.data; return res.data;
}); });
});
} }
); );
}
)
);
const getPublishedContentSite = withCacheKey( const getPublishedContentSite = memoize(async function getPublishedContentSite(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { organizationId: string; siteId: string; siteShareKey: string | undefined } params: {
) => { organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}
) {
'use cache'; 'use cache';
return trace('getPublishedContentSite.uncached', () => {
cacheLife('days');
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'site', tag: 'site',
@@ -621,36 +486,23 @@ const getPublishedContentSite = withCacheKey(
}) })
); );
return trace( return trace('getPublishedContentSite', () => {
`getPublishedContentSite(${params.organizationId}, ${params.siteId}, ${params.siteShareKey})`,
async () => {
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.orgs.getPublishedContentSite( const res = await api.orgs.getPublishedContentSite(
params.organizationId, params.organizationId,
params.siteId, params.siteId,
{ {
shareKey: params.siteShareKey, shareKey: params.siteShareKey,
},
{
...noCacheFetchOptions,
} }
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data; return res.data;
}); });
} });
); });
} });
)
);
const getSiteRedirectBySource = withCacheKey( const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { params: {
organizationId: string; organizationId: string;
@@ -658,189 +510,116 @@ const getSiteRedirectBySource = withCacheKey(
siteShareKey: string | undefined; siteShareKey: string | undefined;
source: string; source: string;
} }
) => { ) {
'use cache'; 'use cache';
return trace('getSiteRedirectBySource.uncached', () => {
cacheTag( cacheTag(
getCacheTag({ getCacheTag({
tag: 'site', tag: 'site',
site: params.siteId, site: params.siteId,
}) })
); );
cacheLife('days');
return trace(
`getSiteRedirectBySource(${params.organizationId}, ${params.siteId}, ${params.siteShareKey}, ${params.source})`,
async () => {
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.orgs.getSiteRedirectBySource( const res = await api.orgs.getSiteRedirectBySource(
params.organizationId, params.organizationId,
params.siteId, params.siteId,
{ {
shareKey: params.siteShareKey, shareKey: params.siteShareKey,
source: params.source, source: params.source,
},
{
...noCacheFetchOptions,
} }
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data;
});
}
);
}
)
);
const getEmbedByUrl = withCacheKey( return res.data;
withoutConcurrentExecution( });
getCloudflareRequestGlobal, });
async (_, input: DataFetcherInput, params: { spaceId: string; url: string }) => { });
const getEmbedByUrl = memoize(async function getEmbedByUrl(
input: DataFetcherInput,
params: {
url: string;
spaceId: string;
}
) {
'use cache'; 'use cache';
cacheTag(
getCacheTag({
tag: 'space',
space: params.spaceId,
})
);
return trace(`getEmbedByUrl(${params.spaceId}, ${params.url})`, async () => { return trace('getEmbedByUrl.uncached', () => {
return wrapDataFetcherError(async () => {
const api = apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(
params.spaceId,
{
url: params.url,
},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('weeks'); cacheLife('weeks');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
return res.data; return res.data;
}); });
}); });
} });
)
);
const searchSiteContent = withCacheKey( const searchSiteContent = memoize(async function searchSiteContent(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0] params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
) => { ) {
'use cache'; 'use cache';
cacheTag(
getCacheTag({
tag: 'site',
site: params.siteId,
})
);
return trace( return trace('searchSiteContent.uncached', () => {
`searchSiteContent(${params.organizationId}, ${params.siteId}, ${params.query})`,
async () => {
return wrapDataFetcherError(async () => {
const { organizationId, siteId, query, scope } = params; const { organizationId, siteId, query, scope } = params;
const api = apiClient(input);
const res = await api.orgs.searchSiteContent( cacheLife('days');
organizationId,
siteId, return wrapDataFetcherError(async () => {
{ const api = await apiClient(input);
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
query, query,
...scope, ...scope,
}, });
{},
{
...noCacheFetchOptions,
}
);
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('hours');
return res.data.items; return res.data.items;
}); });
} });
); });
}
)
);
const renderIntegrationUi = withCacheKey( const renderIntegrationUi = memoize(async function renderIntegrationUi(
withoutConcurrentExecution(
getCloudflareRequestGlobal,
async (
_,
input: DataFetcherInput, input: DataFetcherInput,
params: { integrationName: string; request: RenderIntegrationUI } params: {
) => { integrationName: string;
request: RenderIntegrationUI;
}
) {
'use cache'; 'use cache';
cacheTag(
getCacheTag({
tag: 'integration',
integration: params.integrationName,
})
);
return trace(`renderIntegrationUi(${params.integrationName})`, async () => { return trace('renderIntegrationUi.uncached', () => {
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
cacheLife('days');
return wrapDataFetcherError(async () => { return wrapDataFetcherError(async () => {
const api = apiClient(input); const api = await apiClient(input);
const res = await api.integrations.renderIntegrationUiWithPost( const res = await api.integrations.renderIntegrationUiWithPost(
params.integrationName, params.integrationName,
params.request, params.request
{
...noCacheFetchOptions,
}
); );
cacheTag(...getCacheTagsFromResponse(res));
cacheLife('days');
return res.data; return res.data;
}); });
}); });
} });
)
);
async function* streamAIResponse(
input: DataFetcherInput,
params: Parameters<GitBookDataFetcher['streamAIResponse']>[0]
) {
const api = apiClient(input);
const res = await api.orgs.streamAiResponseInSite(
params.organizationId,
params.siteId,
{
input: params.input,
output: params.output,
model: params.model,
},
{
...noCacheFetchOptions,
}
);
for await (const event of res) {
yield event;
}
}
let loggedServiceBinding = false; let loggedServiceBinding = false;
/** /**
* Create a new API client. * Create a new API client.
*/ */
export function apiClient(input: DataFetcherInput = { apiToken: null }) { export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
const { apiToken } = input; const { apiToken } = input;
let serviceBinding: GitBookAPIServiceBinding | undefined; let serviceBinding: GitBookAPIServiceBinding | undefined;
const cloudflareContext = getCloudflareContext(); try {
if (cloudflareContext) { // HACK: This is a workaround to avoid webpack trying to bundle this cloudflare only module
// @ts-expect-error // @ts-ignore
serviceBinding = cloudflareContext.env.GITBOOK_API as GitBookAPIServiceBinding | undefined; const { env } = await import(
/* webpackIgnore: true */ `${'__cloudflare:workers'.replaceAll('_', '')}`
);
serviceBinding = env.GITBOOK_API;
if (!loggedServiceBinding) { if (!loggedServiceBinding) {
loggedServiceBinding = true; loggedServiceBinding = true;
if (serviceBinding) { if (serviceBinding) {
@@ -851,6 +630,10 @@ export function apiClient(input: DataFetcherInput = { apiToken: null }) {
console.warn(`no service binding for the API (${GITBOOK_API_URL})`); console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
} }
} }
} catch (error) {
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) {
throw error;
}
} }
const api = new GitBookAPI({ const api = new GitBookAPI({
@@ -862,12 +645,3 @@ export function apiClient(input: DataFetcherInput = { apiToken: null }) {
return api; return api;
} }
/**
* Get the tags from the API responses.
*/
function getCacheTagsFromResponse(response: HttpResponse<unknown, unknown>) {
const cacheTagHeader = response.headers.get('x-gitbook-cache-tag');
const tags = !cacheTagHeader ? [] : cacheTagHeader.split(',');
return tags;
}
@@ -1,25 +0,0 @@
import { getCloudflareContext as getCloudflareContextOpenNext } from '@opennextjs/cloudflare';
import { GITBOOK_RUNTIME } from '../env';
/**
* Return the Cloudflare context or null when not running in Cloudflare.
*/
export function getCloudflareContext() {
if (GITBOOK_RUNTIME !== 'cloudflare') {
return null;
}
return getCloudflareContextOpenNext();
}
/**
* Return an object representing the current request.
*/
export function getCloudflareRequestGlobal() {
const context = getCloudflareContext();
if (!context) {
return null;
}
return context.cf;
}
@@ -98,10 +98,6 @@ export async function wrapDataFetcherError<T>(
*/ */
export function getExposableError(error: Error): DataFetcherErrorData { export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) { if (error instanceof GitBookAPIError) {
if (error.code >= 500) {
throw error;
}
return { return {
code: error.code, code: error.code,
message: error.errorMessage, message: error.errorMessage,
@@ -109,10 +105,6 @@ export function getExposableError(error: Error): DataFetcherErrorData {
} }
if (error instanceof DataFetcherError) { if (error instanceof DataFetcherError) {
if (error.code >= 500) {
throw error;
}
return { return {
code: error.code, code: error.code,
message: error.message, message: error.message,
@@ -4,5 +4,3 @@ export * from './pages';
export * from './urls'; export * from './urls';
export * from './errors'; export * from './errors';
export * from './lookup'; export * from './lookup';
export * from './proxy';
export * from './visitor';
+27 -82
View File
@@ -1,102 +1,48 @@
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 { GitBookAPI, 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 { /**
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
url: string; url: string;
visitorAuthToken: string | null;
redirectOnError: boolean; redirectOnError: boolean;
apiToken: string | null; apiToken: string | null;
visitorPayload: SiteVisitorPayload;
}
/**
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'resolvePublishedContentByUrl',
name: url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
},
});
}
/**
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*
* @deprecated use resolvePublishedContentByUrl.
*
*/
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'getPublishedContentByURL',
name: url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url,
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
redirectOnError: input.redirectOnError,
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{ signal }
)
)
);
},
});
}
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
async function lookupPublishedContentByUrl(input: {
url: string;
fetchLookupAPIResult: (args: {
url: string;
signal: AbortSignal;
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> { }): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url); const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL); const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url); const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => { const result = await race(lookup.urls, async (alternative, { signal }) => {
const callResult = await input.fetchLookupAPIResult({ const api = await apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'getPublishedContentByURL',
name: alternative.url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url, url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
},
{
signal, signal,
}); }
)
)
);
if (callResult.error) { if (callResult.error) {
if (alternative.primary) { if (alternative.primary) {
@@ -154,7 +100,6 @@ async function lookupPublishedContentByUrl(input: {
const siteResult: PublishedSiteContentLookup = { const siteResult: PublishedSiteContentLookup = {
...data, ...data,
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
basePath: joinPath(data.basePath, lookup.basePath ?? ''), basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath), pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}), ...(changeRequest ? { changeRequest } : {}),
@@ -1,52 +1,39 @@
import { describe, expect, it, mock } from 'bun:test'; import { describe, expect, it, mock } from 'bun:test';
import { AsyncLocalStorage } from 'node:async_hooks'; import { memoize } from './memoize';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
describe('withoutConcurrentExecution', () => { describe('memoize', () => {
it('should memoize the function based on the cache key', async () => { it('should memoize the function', async () => {
const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b);
const memoized = withoutConcurrentExecution(() => null, fn);
const p1 = memoized('c1', 1, 2);
const p2 = memoized('c1', 1, 2);
const p3 = memoized('c3', 2, 3);
expect(await p1).toBe(await p2);
expect(await p1).not.toBe(await p3);
expect(fn.mock.calls.length).toBe(2);
});
it('should support caching per request', async () => {
const fn = mock(async () => Math.random()); const fn = mock(async () => Math.random());
const memoized = memoize(fn);
expect(await memoized()).toBe(await memoized());
});
const request1 = { id: 'request1' }; it('should memoize the function with different arguments', async () => {
const request2 = { id: 'request2' }; const fn = mock(async (a: number, b: number) => a + b);
const memoized = memoize(fn);
const requestContext = new AsyncLocalStorage<{ id: string }>(); expect(await memoized(1, 2)).toBe(await memoized(1, 2));
expect(fn.mock.calls.length).toBe(1);
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn); expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
// Both in the same request
const promise1 = requestContext.run(request1, () => memoized('c1'));
const promise2 = requestContext.run(request1, () => memoized('c1'));
// In a different request
const promise3 = requestContext.run(request2, () => memoized('c1'));
expect(await promise1).toBe(await promise2);
expect(await promise1).not.toBe(await promise3);
expect(fn.mock.calls.length).toBe(2); 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);
}); });
describe('withCacheKey', () => { it('should wrap concurrent async calls', async () => {
it('should wrap the function by passing the cache key', async () => { const fn = mock(async () => Math.random());
const fn = mock( const memoized = memoize(fn);
async (cacheKey: string, arg: { a: number; b: number }, c: number) => const promise1 = memoized();
`${cacheKey}, result=${arg.a + arg.b + c}` const promise2 = memoized();
); expect(await promise1).toBe(await promise2);
const memoized = withCacheKey(fn);
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
expect(fn.mock.calls.length).toBe(1); expect(fn.mock.calls.length).toBe(1);
}); });
}); });
+10 -54
View File
@@ -1,61 +1,17 @@
/** import pMemoize from 'p-memoize';
* Wrap a function by preventing concurrent executions of the same function.
* With a logic to work per-request in Cloudflare Workers.
*/
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
getGlobalContext: () => object | null | undefined,
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
return (key: string, ...args: ArgsType) => {
const globalContext = getGlobalContext() ?? globalThis;
/** /**
* Cache storage that is scoped to the current request when executed in Cloudflare Workers, * We wrap 'use cache' calls in a p-memoize function to avoid
* to avoid "Cannot perform I/O on behalf of a different request" errors. * executing the function multiple times when doing concurrent calls.
*
* Hopefully one day this can be done directly by 'use cache'.
*/ */
const promiseCache = export function memoize<F extends (...args: any[]) => any>(f: F): F {
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>(); return pMemoize(f, {
globalPromiseCache.set(globalContext, promiseCache); cacheKey: (args) => {
const concurrent = promiseCache.get(key);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped(key, ...args);
return result;
} finally {
promiseCache.delete(key);
}
})();
promiseCache.set(key, promise);
return promise;
};
}
/**
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args)); return JSON.stringify(deepSortValue(args));
},
});
} }
function deepSortValue(value: unknown): unknown { function deepSortValue(value: unknown): unknown {
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
describe('isProxyRequest', () => {
it('should return true for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(isProxyRequest(proxyRequestURL)).toBe(true);
});
it('should return false for non-proxy requests', () => {
const nonProxyRequestURL = new URL('https://example.com/docs/foo/hello/world');
expect(isProxyRequest(nonProxyRequestURL)).toBe(false);
});
});
describe('getProxyRequestIdentifier', () => {
it('should return the correct identifier for proxy requests', () => {
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
expect(getProxyRequestIdentifier(proxyRequestURL)).toBe('sites/site_foo');
});
});
-15
View File
@@ -1,15 +0,0 @@
/**
* Check if the request to the site was through a proxy.
*/
export function isProxyRequest(requestURL: URL): boolean {
return (
requestURL.host === 'proxy.gitbook.site' || requestURL.host === 'proxy.gitbook-staging.site'
);
}
export function getProxyRequestIdentifier(requestURL: URL): string {
// For proxy requests, we extract the site ID from the pathname
// e.g. https://proxy.gitbook.site/site/siteId/...
const pathname = requestURL.pathname.slice(1).split('/');
return pathname.slice(0, 2).join('/');
}
-20
View File
@@ -97,15 +97,6 @@ export interface GitBookDataFetcher {
path: string; path: string;
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>; }): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
/**
* Get the markdown content of a page by its path.
*/
getRevisionPageMarkdown(params: {
spaceId: string;
revisionId: string;
pageId: string;
}): Promise<DataFetcherResponse<string>>;
/** /**
* Get a document by its space ID and document ID. * Get a document by its space ID and document ID.
*/ */
@@ -179,15 +170,4 @@ export interface GitBookDataFetcher {
integrationName: string; integrationName: string;
request: api.RenderIntegrationUI; request: api.RenderIntegrationUI;
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>; }): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
/**
* Stream an AI response.
*/
streamAIResponse(params: {
organizationId: string;
siteId: string;
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
} }
@@ -1,41 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { getVisitorAuthBasePath } from './visitor';
describe('getVisitorAuthBasePath', () => {
it('should return the correct base path for proxy requests', () => {
expect(
getVisitorAuthBasePath(
new URL('https://proxy.gitbook.site/sites/site_foo/hello/world'),
{
site: 'site_foo',
siteSpace: 'sitesp_foo',
basePath: '/foo',
siteBasePath: '/foo',
organization: 'org_foo',
space: 'space_foo',
pathname: '/hello/world',
complete: false,
apiToken: 'api_token_foo',
canonicalUrl: 'https://example.com/docs/foo/hello/world',
}
)
).toBe('/sites/site_foo/');
});
it('should return the correct base path for non-proxy requests', () => {
expect(
getVisitorAuthBasePath(new URL('https://example.com/docs/foo/hello/world'), {
site: 'site_foo',
siteSpace: 'sitesp_foo',
basePath: '/foo/',
siteBasePath: '/foo/',
organization: 'org_foo',
space: 'space_foo',
pathname: '/hello/world',
complete: false,
apiToken: 'api_token_foo',
canonicalUrl: 'https://example.com/docs/foo/hello/world',
})
).toBe('/foo/');
});
});
@@ -1,19 +0,0 @@
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
import type { PublishedSiteContent } from '@gitbook/api';
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
/**
* Get the appropriate base path for the visitor authentication cookie.
*/
export function getVisitorAuthBasePath(
siteRequestURL: URL,
siteURLData: PublishedSiteContent
): string {
// The siteRequestURL for proxy requests is of the form `https://proxy.gitbook.com/site/siteId/...`
// In such cases, we should not use the resolved siteBasePath for the cookie because for subsequent requests
// we will not have the siteBasePath in the request URL in order to retrieve the cookie. So we use the
// proxy identifier instead.
return isProxyRequest(siteRequestURL)
? withLeadingSlash(withTrailingSlash(getProxyRequestIdentifier(siteRequestURL)))
: siteURLData.siteBasePath;
}
-26
View File
@@ -6,14 +6,6 @@ import 'server-only';
* and not from the `process.env` object. * and not from the `process.env` object.
*/ */
/**
* Runtime environment.
*/
export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as
| 'vercel'
| 'cloudflare'
| 'unknown';
/** /**
* Main host on which GitBook is running. * Main host on which GitBook is running.
*/ */
@@ -85,15 +77,6 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY = export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null; process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
/**
* Mode used for resizing images.
*/
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
'GITBOOK_IMAGE_RESIZE_MODE',
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
['cdn-cgi', 'cf-fetch']
);
/** /**
* Endpoint where icons are served. * Endpoint where icons are served.
*/ */
@@ -109,12 +92,3 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
* Secret used to validate requests from the GitBook app. * Secret used to validate requests from the GitBook app.
*/ */
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null; export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
if (!enumValues.includes(value as T)) {
throw new Error(
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
);
}
return value as T;
}
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
describe('checkIsSizableImageURL', () => {
it('should return Skip for non-parsable URLs', () => {
expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip);
});
it('should return Skip for non-http(s) URLs', () => {
expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip);
expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip);
});
it('should return Skip for localhost URLs', () => {
expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe(
SizableImageAction.Skip
);
expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip);
});
it('should return Skip for GitBook image URLs', () => {
expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe(
SizableImageAction.Skip
);
});
it('should return Resize for supported image extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.png')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe(
SizableImageAction.Resize
);
});
it('should return Resize for URLs without extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize);
});
it('should return Passthrough for unsupported image extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe(
SizableImageAction.Passthrough
);
expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe(
SizableImageAction.Passthrough
);
});
it('should handle URLs with query parameters correctly', () => {
expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe(
SizableImageAction.Passthrough
);
});
it('should be case-insensitive for extensions', () => {
expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe(
SizableImageAction.Resize
);
expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe(
SizableImageAction.Resize
);
});
});
@@ -1,43 +0,0 @@
import { getExtension } from '@/lib/paths';
export enum SizableImageAction {
Resize = 'resize',
Skip = 'skip',
Passthrough = 'passthrough',
}
/**
* https://developers.cloudflare.com/images/transform-images/#supported-input-formats
*/
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): SizableImageAction {
if (!URL.canParse(input)) {
return SizableImageAction.Skip;
}
const parsed = new URL(input);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return SizableImageAction.Skip;
}
if (parsed.hostname === 'localhost') {
return SizableImageAction.Skip;
}
if (parsed.pathname.includes('/~gitbook/image')) {
return SizableImageAction.Skip;
}
const extension = getExtension(parsed.pathname).toLowerCase();
if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
// If no extension, we consider it resizable.
return SizableImageAction.Resize;
}
return SizableImageAction.Passthrough;
}
@@ -1,22 +1,45 @@
import 'server-only'; import 'server-only';
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env'; import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
import type { GitBookLinker } from '../links'; import type { GitBookLinker } from '../links';
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
import { getImageSize } from './resizer';
import { type SignatureVersion, generateImageSignature } from './signatures'; import { type SignatureVersion, generateImageSignature } from './signatures';
import type { ImageResizer } from './types'; import type { ImageResizer } from './types';
interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
/** /**
* Create an image resizer for a rendering context. * Create an image resizer for a rendering context.
*/ */
export function createImageResizer({ export function createImageResizer({
imagesContextId, host,
linker, linker,
}: { }: {
/** The linker to use to create URLs. */ /** The linker to use to create URLs. */
linker: GitBookLinker; linker: GitBookLinker;
/** The site identifier to use for verifying the image signature. */ /** The host name of the current site. */
imagesContextId: string; host: string;
}): ImageResizer { }): ImageResizer {
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) { if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
return createNoopImageResizer(); return createNoopImageResizer();
@@ -24,7 +47,7 @@ export function createImageResizer({
return { return {
getResizedImageURL: (urlInput) => { getResizedImageURL: (urlInput) => {
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) { if (!checkIsSizableImageURL(urlInput)) {
return null; return null;
} }
@@ -35,7 +58,7 @@ export function createImageResizer({
return async (options) => { return async (options) => {
cachedSignature ??= await generateImageSignature({ cachedSignature ??= await generateImageSignature({
imagesContextId, host,
url: urlInput, url: urlInput,
}); });
@@ -64,7 +87,7 @@ export function createImageResizer({
}, },
getImageSize: async (input, options) => { getImageSize: async (input, options) => {
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) { if (!checkIsSizableImageURL(input)) {
return null; return null;
} }
@@ -83,6 +106,124 @@ export function createNoopImageResizer(): ImageResizer {
}; };
} }
/**
* Check if a URL is an HTTP URL.
*/
export function checkIsHttpURL(input: string | URL): boolean {
if (!URL.canParse(input)) {
return false;
}
const parsed = new URL(input);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}
/**
* Check if an image URL is resizable.
* Skip it for non-http(s) URLs (data, etc).
* Skip it for SVGs.
* Skip it for GitBook images (to avoid recursion).
*/
export function checkIsSizableImageURL(input: string): boolean {
if (!URL.canParse(input)) {
return false;
}
if (input.includes('/~gitbook/image')) {
return false;
}
const parsed = new URL(input);
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
return false;
}
if (!checkIsHttpURL(parsed)) {
return false;
}
return true;
}
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (!checkIsSizableImageURL(input)) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (_error) {
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
const parsed = new URL(input);
if (parsed.protocol === 'data:') {
throw new Error('Cannot resize data: URLs');
}
if (parsed.hostname === 'localhost') {
throw new Error('Cannot resize localhost URLs');
}
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
}
return await fetch(
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`,
{
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
}
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
/** /**
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost. * Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
* We protect against it by converting to a special token, and then parsing * We protect against it by converting to a special token, and then parsing
@@ -1,13 +0,0 @@
import { getProxyRequestIdentifier, isProxyRequest } from '../data';
/**
* Get the site identifier to use for image resizing for an incoming request.
* This identifier can be obtained before resolving the request URL.
*/
export function getImageResizingContextId(url: URL): string {
if (isProxyRequest(url)) {
return getProxyRequestIdentifier(url);
}
return url.host;
}
@@ -2,6 +2,3 @@ export * from './types';
export * from './createImageResizer'; export * from './createImageResizer';
export * from './signatures'; export * from './signatures';
export * from './utils'; export * from './utils';
export * from './getImageResizingContextId';
export * from './resizer';
export * from './checkIsSizableImageURL';
@@ -1,48 +0,0 @@
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
/**
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
*/
export async function resizeImageWithCDNCgi(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
// we need to use the Cloudflare Image Resize API directly.
if (!GITBOOK_IMAGE_RESIZE_URL) {
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
}
const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
resizeOptions
)}/${encodeURIComponent(input)}`;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cdn-cgi: ${resizeURL}`);
return copyImageResponse(
await fetch(resizeURL, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
})
);
}
function stringifyOptions(options: CloudflareImageOptions): string {
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
return `${rest}${rest ? ',' : ''}${key}=${value}`;
}, '');
}
@@ -1,34 +0,0 @@
import type { CloudflareImageOptions } from './types';
import { copyImageResponse } from './utils';
/**
* Resize an image by doing a request to the image itself using the Cloudflare fetch.
* https://developers.cloudflare.com/images/transform-images/transform-via-workers/
*
* This method doesn't work in Cloudflare Pages and is only supported in workers.
*/
export async function resizeImageWithCFFetch(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const { signal, ...resizeOptions } = options;
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
console.log(`resize image using cf-fetch: ${input}`);
return copyImageResponse(
await fetch(input, {
headers: {
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
Accept:
resizeOptions.format === 'json'
? 'application/json'
: `image/${resizeOptions.format || 'jpeg'}`,
},
signal,
cf: { image: resizeOptions },
})
);
}
@@ -1,2 +0,0 @@
export * from './types';
export * from './resizeImage';
@@ -1,72 +0,0 @@
import 'server-only';
import assertNever from 'assert-never';
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
import { resizeImageWithCDNCgi } from './cdn-cgi';
import { resizeImageWithCFFetch } from './cf-fetch';
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
/**
* Get the size of an image.
*/
export async function getImageSize(
input: string,
defaultSize: Partial<CloudflareImageOptions> = {}
): Promise<{ width: number; height: number } | null> {
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
return null;
}
try {
const response = await resizeImage(input, {
// Abort the request after 2 seconds to avoid blocking rendering for too long
signal: AbortSignal.timeout(2000),
// Measure size and resize it to the most common size
// to optimize caching
...defaultSize,
format: 'json',
anim: false,
});
const json = (await response.json()) as CloudflareImageJsonFormat;
return {
width: json.original.width,
height: json.original.height,
};
} catch (error) {
console.warn(`Error getting image size for ${input}:`, error);
return null;
}
}
/**
* Execute a Cloudflare Image Resize operation on an image.
*/
export async function resizeImage(
input: string,
options: CloudflareImageOptions & {
signal?: AbortSignal;
}
): Promise<Response> {
const action = checkIsSizableImageURL(input);
if (action === SizableImageAction.Skip) {
throw new Error(
'Cannot resize this image, this function should have never been called on this url'
);
}
if (action === SizableImageAction.Passthrough) {
return fetch(input, {
signal: options.signal,
});
}
switch (GITBOOK_IMAGE_RESIZE_MODE) {
case 'cdn-cgi':
return resizeImageWithCDNCgi(input, options);
case 'cf-fetch':
return resizeImageWithCFFetch(input, options);
default:
assertNever(GITBOOK_IMAGE_RESIZE_MODE);
}
}
@@ -1,23 +0,0 @@
export interface CloudflareImageJsonFormat {
width: number;
height: number;
original: {
file_size: number;
width: number;
height: number;
format: string;
};
}
/**
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
*/
export interface CloudflareImageOptions {
format?: 'webp' | 'avif' | 'json' | 'jpeg';
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
width?: number;
height?: number;
dpr?: number;
anim?: boolean;
quality?: number;
}
@@ -1,7 +0,0 @@
/**
* Copy a response to make sure it can be mutated by the rest of the middleware.
* To avoid errors "Can't modify immutable headers".
*/
export function copyImageResponse(response: Response) {
return new Response(response.body, response);
}
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = { type SignFnInput = {
url: string; url: string;
imagesContextId: string; host: string;
}; };
type SignFn = (input: SignFnInput) => MaybePromise<string>; type SignFn = (input: SignFnInput) => MaybePromise<string>;
@@ -32,11 +32,6 @@ export async function verifyImageSignature(
): Promise<boolean> { ): Promise<boolean> {
const generator = IMAGE_SIGNATURE_FUNCTIONS[version]; const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input); const generated = await generator(input);
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
console.log(
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
);
return generated === signature; return generated === signature;
} }
@@ -65,14 +60,12 @@ const generateSignatureV2: SignFn = async (input) => {
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set'); assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
const all = [ const all = [
input.url, input.url,
input.imagesContextId, // The hostname is used to avoid serving images from other sites on the same domain input.host, // The hostname is used to avoid serving images from other sites on the same domain
GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
] ]
.filter(Boolean) .filter(Boolean)
.join(':'); .join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
const signature = fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
return signature;
}; };
// Reused buffer for FNV-1a hashing in the v1 algorithm // Reused buffer for FNV-1a hashing in the v1 algorithm
-5
View File
@@ -93,11 +93,6 @@ export function createLinker(
}, },
toAbsoluteURL(absolutePath: string): string { toAbsoluteURL(absolutePath: string): string {
// If the path is already a full URL, we return it as is.
if (URL.canParse(absolutePath)) {
return absolutePath;
}
if (!servedOn.host) { if (!servedOn.host) {
return absolutePath; return absolutePath;
} }
+2 -3
View File
@@ -1,6 +1,5 @@
import { CustomizationThemeMode } from '@gitbook/api'; import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import type { SiteURLData } from './context';
export enum MiddlewareHeaders { export enum MiddlewareHeaders {
/** /**
@@ -58,7 +57,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
* Get the site URL data from the middleware headers. * Get the site URL data from the middleware headers.
* This function should only be called in a server action or a dynamic route. * This function should only be called in a server action or a dynamic route.
*/ */
export async function getSiteURLDataFromMiddleware(): Promise<SiteURLData> { export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
const headersList = await headers(); const headersList = await headers();
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData); const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
+35 -106
View File
@@ -10,22 +10,19 @@ import {
type ResponseCookies, type ResponseCookies,
getPathScopedCookieName, getPathScopedCookieName,
getResponseCookiesForVisitorAuth, getResponseCookiesForVisitorAuth,
getVisitorData, getVisitorToken,
normalizeVisitorAuthURL, normalizeVisitorAuthURL,
} from '@/lib/visitors'; } from '@/lib/visitor-token';
import { serveResizedImage } from '@/routes/image'; import { serveResizedImage } from '@/routes/image';
import { import {
DataFetcherError, DataFetcherError,
getPublishedContentByURL, getPublishedContentByURL,
getVisitorAuthBasePath,
normalizeURL, normalizeURL,
resolvePublishedContentByUrl,
throwIfDataError, throwIfDataError,
} from '@v2/lib/data'; } from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env'; import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
import { getImageResizingContextId } from '@v2/lib/images';
import { MiddlewareHeaders } from '@v2/lib/middleware'; import { MiddlewareHeaders } from '@v2/lib/middleware';
import type { SiteURLData } from './lib/context';
export const config = { export const config = {
matcher: [ matcher: [
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)', '/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
@@ -34,15 +31,6 @@ export const config = {
type URLWithMode = { url: URL; mode: 'url' | 'url-host' }; type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
/**
* Temporary list of hosts to test adaptive content using the new resolution API.
*/
const ADAPTIVE_CONTENT_HOSTS = [
'docs.gitbook.com',
'adaptive-docs.gitbook-staging.com',
'enriched-content-playground.gitbook-staging.io',
];
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
try { try {
const requestURL = new URL(request.url); const requestURL = new URL(request.url);
@@ -75,8 +63,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return null; return null;
} }
const { url: siteRequestURL, mode } = match; const { url: siteURL, mode } = match;
const imagesContextId = getImageResizingContextId(siteRequestURL);
/** /**
* Serve image resizing requests (all requests containing `/~gitbook/image`). * Serve image resizing requests (all requests containing `/~gitbook/image`).
@@ -86,31 +73,26 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image) * In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image) * In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
*/ */
if (siteRequestURL.pathname.endsWith('/~gitbook/image')) { if (siteURL.pathname.endsWith('/~gitbook/image')) {
return await serveResizedImage(request, { return await serveResizedImage(request, {
imagesContextId: imagesContextId, host: siteURL.host,
}); });
} }
// //
// Detect and extract the visitor authentication token from the request // Detect and extract the visitor authentication token from the request
// //
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({ // @ts-ignore - request typing
const visitorToken = getVisitorToken({
cookies: request.cookies.getAll(), cookies: request.cookies.getAll(),
url: siteRequestURL, url: siteURL,
}); });
const withAPIToken = async (apiToken: string | null) => { const withAPIToken = async (apiToken: string | null) => {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError( const siteURLData = await throwIfDataError(
resolve({ getPublishedContentByURL({
url: siteRequestURL.toString(), url: siteURL.toString(),
visitorPayload: { visitorAuthToken: visitorToken?.token ?? null,
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
},
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow // When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie. // redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired. // For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
@@ -121,13 +103,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
apiToken, apiToken,
}) })
); );
const cookies: ResponseCookies = [];
const cookies: ResponseCookies = visitorParamsCookie
? [
// If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation.
visitorParamsCookie,
]
: [];
// //
// Handle redirects // Handle redirects
@@ -158,32 +134,20 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return NextResponse.redirect(siteURLData.redirect); return NextResponse.redirect(siteURLData.redirect);
} }
cookies.push( cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
...getResponseCookiesForVisitorAuth(
getVisitorAuthBasePath(siteRequestURL, siteURLData),
visitorToken
)
);
// We use the host/origin from the canonical URL to ensure the links are
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
let incomingURL = requestURL;
// For cases where the site is proxied, we use the canonical URL
// as the incoming URL along with all the search params from the request.
if (mode !== 'url') {
incomingURL = siteCanonicalURL;
incomingURL.search = requestURL.search;
}
// //
// Make sure the URL is clean of any va token after a successful lookup // Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response // The token is stored in a cookie that is set on the redirect response
// //
const incomingURLWithoutToken = normalizeVisitorAuthURL(incomingURL); const incomingURL = mode === 'url' ? requestURL : siteURL;
if (incomingURLWithoutToken.toString() !== incomingURL.toString()) { const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
if (
requestURLWithoutToken !== incomingURL &&
requestURLWithoutToken.toString() !== incomingURL.toString()
) {
return writeResponseCookies( return writeResponseCookies(
NextResponse.redirect(incomingURLWithoutToken.toString()), NextResponse.redirect(requestURLWithoutToken.toString()),
cookies cookies
); );
} }
@@ -196,39 +160,19 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// (customization override, theme, etc) // (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static'; let routeType: 'dynamic' | 'static' = 'static';
// We pick only stable data from the siteURL data to prevent re-rendering of
// the root layout when changing pages..
const stableSiteURLData: SiteURLData = {
site: siteURLData.site,
siteSection: siteURLData.siteSection,
siteSpace: siteURLData.siteSpace,
siteBasePath: siteURLData.siteBasePath,
basePath: siteURLData.basePath,
space: siteURLData.space,
organization: siteURLData.organization,
changeRequest: siteURLData.changeRequest,
revision: siteURLData.revision,
shareKey: siteURLData.shareKey,
apiToken: siteURLData.apiToken,
imagesContextId: imagesContextId,
};
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType); requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
requestHeaders.set(MiddlewareHeaders.URLMode, mode); requestHeaders.set(MiddlewareHeaders.URLMode, mode);
requestHeaders.set( requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${siteURLData.basePath}`);
MiddlewareHeaders.SiteURL, requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
`${siteCanonicalURL.origin}${siteURLData.basePath}`
);
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData));
// Preview of customization/theme // Preview of customization/theme
const customization = siteRequestURL.searchParams.get('customization'); const customization = siteURL.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) { if (customization && validateSerializedCustomization(customization)) {
routeType = 'dynamic'; routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Customization, customization); requestHeaders.set(MiddlewareHeaders.Customization, customization);
} }
const theme = siteRequestURL.searchParams.get('theme'); const theme = siteURL.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) { if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
routeType = 'dynamic'; routeType = 'dynamic';
requestHeaders.set(MiddlewareHeaders.Theme, theme); requestHeaders.set(MiddlewareHeaders.Theme, theme);
@@ -244,7 +188,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
requestHeaders.set('x-forwarded-host', request.nextUrl.host); requestHeaders.set('x-forwarded-host', request.nextUrl.host);
requestHeaders.set('origin', request.nextUrl.origin); requestHeaders.set('origin', request.nextUrl.origin);
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`; const siteURLWithoutProtocol = `${siteURL.host}${siteURLData.basePath}`;
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent( const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
siteURLData.pathname siteURLData.pathname
); );
@@ -256,14 +200,12 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
mode, mode,
encodeURIComponent(siteURLWithoutProtocol), encodeURIComponent(siteURLWithoutProtocol),
encodeURIComponent( encodeURIComponent(
rison.encode( rison.encode({
// rison can't encode undefined values ...siteURLData,
Object.fromEntries( // The pathname is passed as the next segment of the route and should not cause this segment to change
Object.entries(stableSiteURLData).filter( // based on the page being visited
([_, v]) => typeof v !== 'undefined' pathname: '<DO_NOT_USE>',
) })
)
)
), ),
pathname, pathname,
].join('/'); ].join('/');
@@ -291,10 +233,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
}; };
// For https://preview/<siteURL> requests, // For https://preview/<siteURL> requests,
if (siteRequestURL.hostname === 'preview') { if (siteURL.hostname === 'preview') {
return serveWithQueryAPIToken( return serveWithQueryAPIToken(
// We scope the API token to the site ID. // We scope the API token to the site ID.
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`, `${siteURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
request, request,
withAPIToken withAPIToken
); );
@@ -447,26 +389,13 @@ function encodePathInSiteContent(rawPathname: string): {
return { pathname }; return { pathname };
} }
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
if (pathname.match(/\.md$/)) {
const pagePathWithoutMD = pathname.slice(0, -3);
return {
pathname: `~gitbook/markdown/${encodeURIComponent(pagePathWithoutMD)}`,
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
routeType: 'static',
};
}
switch (pathname) { switch (pathname) {
case '~gitbook/icon': case '~gitbook/icon':
return { pathname };
case 'llms.txt': case 'llms.txt':
case 'sitemap.xml': case 'sitemap.xml':
case 'sitemap-pages.xml': case 'sitemap-pages.xml':
case 'robots.txt': case 'robots.txt':
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static return { pathname };
// as they only depend on the site structure / pages.
return { pathname, routeType: 'static' };
case '~gitbook/pdf': case '~gitbook/pdf':
// PDF routes are always dynamic as they depend on the search params. // PDF routes are always dynamic as they depend on the search params.
return { pathname, routeType: 'dynamic' }; return { pathname, routeType: 'dynamic' };
-149
View File
@@ -1,149 +0,0 @@
{
"main": ".open-next/worker.js",
"name": "gitbook-open-v2",
"compatibility_date": "2025-04-14",
"compatibility_flags": [
"nodejs_compat",
"allow_importable_env",
"global_fetch_strictly_public"
],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"observability": {
"enabled": true
},
"vars": {
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
},
"env": {
"preview": {
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-preview"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-preview"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-prod-api-cache"
}
]
// No durable objects on preview, as they block the generation of preview URLs
// and we don't need tags invalidation on preview
},
"staging": {
"routes": [
{
"pattern": "open-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
},
{
"pattern": "static-2c.gitbook-staging.com/*",
"zone_name": "gitbook-staging.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-staging"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-staging"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-staging-api-cache"
}
],
"tail_consumers": [
{
"service": "gitbook-x-staging-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
},
"production": {
"vars": {
// This is a bit misleading, but it means that we can have 100 concurrent revalidations
// This means that we'll have up to 20 durable objects instance running at the same time
"MAX_REVALIDATE_CONCURRENCY": "20"
},
"routes": [
{
"pattern": "open-2c.gitbook.com/*",
"zone_name": "gitbook.com"
},
{
"pattern": "static-2c.gitbook.com/*",
"zone_name": "gitbook.com"
}
],
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "gitbook-open-v2-cache-production"
}
],
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "gitbook-open-v2-production"
},
{
"binding": "GITBOOK_API",
"service": "gitbook-x-prod-api-cache"
}
],
"tail_consumers": [
{
"service": "gitbook-x-prod-tail"
}
],
"durable_objects": {
"bindings": [
{
"name": "NEXT_CACHE_DO_QUEUE",
"class_name": "DOQueueHandler"
},
{
"name": "NEXT_TAG_CACHE_DO_SHARDED",
"class_name": "DOShardedTagCache"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
}
]
}
}
}
+50
View File
@@ -0,0 +1,50 @@
main = ".open-next/worker.js"
name = "gitbook-open-v2"
compatibility_date = "2025-03-11"
compatibility_flags = ["nodejs_compat", "allow_importable_env"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
observability = { enabled = true }
[env.preview]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "b7dd9cf58bf2458f84812a2d83b3760c" } # gitbook-open-v2-cache-preview
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "f59ddb40-ad72-4312-9395-0ac6a129af8e", database_name = "gitbook-open-v2-tags-preview" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-preview" },
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
]
[env.staging]
routes = [
{ pattern = "open-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" },
{ pattern = "static-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" }
]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "a446e25f12b741afb185f1e5b4474f0a" } # gitbook-open-v2-cache-staging
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "9df62e39-1f35-4066-83aa-e9b8ed3ac8d5", database_name = "gitbook-open-v2-tags-staging" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-staging" },
{ binding = "GITBOOK_API", service = "gitbook-x-staging-api-cache" }
]
[env.production]
routes = [
{ pattern = "open-2c.gitbook.com/*", zone_name = "gitbook.com" },
{ pattern = "static-2c.gitbook.com/*", zone_name = "gitbook.com" }
]
kv_namespaces = [
{ binding = "NEXT_CACHE_WORKERS_KV", id = "72379746280d4e79acf24440eea950dc" } # gitbook-open-v2-cache-production
]
d1_databases = [
{ binding = "NEXT_CACHE_D1", database_id = "a6f16fce-5f45-43a9-89a4-7b83ddf25b77", database_name = "gitbook-open-v2-tags-production" }
]
services = [
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-production" },
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
]
+1 -178
View File
@@ -1,182 +1,5 @@
# gitbook # gitbook
## 0.12.0
### Minor Changes
- 8339e91: Fix images in reusable content across spaces.
- 326e28e: Design tweaks to code blocks and OpenAPI pages
- 3119066: Add support for reusable content across spaces.
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
### Patch Changes
- c4ebb3f: Fix openapi-select hover in responses
- aed79fd: Decrease rounding of header logo
- 42ca7e1: Fix openapi CR preview
- e6ddc0f: Fix URL in sitemap
- 5e975ab: Fix code highlighting for HTTP
- 5d504ff: Fix resolution of links in reusable contents
- 95a1f65: Better print layouts: wrap code blocks & force table column auto-sizing
- 0499966: Fix invalid sitemap.xml generated with relative URLs instead of absolute ones
- 2a805cc: Change OpenAPI schema-optional from `info` to `tint` color
- 580101d: Fix schemas disclosure label causing client error
- 12a455d: Fix OpenAPI layout issues
- 97b7c79: Increase logging around caching behaviour causing page crashes.
- 373f18f: Prevent section group popovers from opening on click
- 3f29206: Update the regex for validating site redirect
- 0c973a3: Always link main logo to the root of the site
- ae5f1ab: Change `Dropdown`s to use Radix's `DropdownMenu`
- 0e201d5: Add border to filled sidebar on gradient theme
- dd043df: Revert investigation work around URL caches.
- 89a5816: Fix OpenAPI disclosure label ("Show properties") misalignment on mobile
- Updated dependencies [c3f6b8c]
- Updated dependencies [d00dc8c]
- Updated dependencies [42ca7e1]
- Updated dependencies [326e28e]
- Updated dependencies [5e975ab]
- Updated dependencies [f7a3470]
- Updated dependencies [580101d]
- Updated dependencies [20ebecb]
- Updated dependencies [80cb52a]
- Updated dependencies [cb5598d]
- Updated dependencies [c6637b0]
- Updated dependencies [a3ec264]
- @gitbook/colors@0.3.3
- @gitbook/openapi-parser@2.1.4
- @gitbook/react-openapi@1.3.0
## 0.11.1
### Patch Changes
- Updated dependencies [ebc39e9]
- Updated dependencies [b6b09d4]
- @gitbook/react-openapi@1.2.1
## 0.11.0
### Minor Changes
- d67699a: Add OpenAPI Webhook block
### Patch Changes
- 4b8a621: Show sections tabs only if there is at least two sections
- 8ed1bda: Translate OpenAPI blocks
- 7588cfe: Improve OpenAPIResponses examples and schemas
- Updated dependencies [eeb977f]
- Updated dependencies [3363a18]
- Updated dependencies [d67699a]
- Updated dependencies [8ed1bda]
- Updated dependencies [7588cfe]
- Updated dependencies [ad1dc0b]
- @gitbook/react-openapi@1.2.0
## 0.10.1
### Patch Changes
- Updated dependencies [77397ca]
- @gitbook/cache-tags@0.3.1
## 0.10.0
### Minor Changes
- b62b101: Do not set cookie to identify visitor for insights when disabled.
### Patch Changes
- 95ea22d: Cache AI Page Link summary
- daf41fc: Tweak footer design (and refactor)
- de53946: Fix security issue with injection of "javacript:` url in the back button of PDFs
- b92ecfa: Implement retry logic for the DO cache to prevent when revalidating content.
- 528eee3: Add superscript and subscript text rendering
- aa3357a: Fix OpenAPISchemas description padding
- 168a4fa: Add support for buttons to GitBook.
- 70c4182: Improve OpenAPI schema style
- 2b6c593: Remove stable from x-stability
- 580f7ad: Improve the error message returned by the revalidate endpoint.
- cbd768a: Improve OpenAPI codesample (add OpenAPISelect component)
- c765463: Fix ogimage generation crashing when site is using a custom WOFF2 font
- e59076a: Improve OpenAPI schemas block ungrouped style. Classnames have changed, please refer to this PR to update GBX.
- 29aaba5: Override Scalar's overscroll-behavior
- 90ead98: Better error handling in cache revalidation.
- Updated dependencies [116575c]
- Updated dependencies [cdffd7c]
- Updated dependencies [70c4182]
- Updated dependencies [2b6c593]
- Updated dependencies [cbd768a]
- Updated dependencies [e59076a]
- Updated dependencies [eedefdd]
- Updated dependencies [23cedd2]
- @gitbook/cache-tags@0.3.0
- @gitbook/colors@0.3.2
- @gitbook/react-openapi@1.1.10
- @gitbook/openapi-parser@2.1.3
## 0.9.2
### Patch Changes
- da7b369: Fix missing headers in OpenAPIResponses
- 139a805: Fix OpenAPI enum display
- Updated dependencies [da7b369]
- Updated dependencies [da485f5]
- Updated dependencies [139a805]
- @gitbook/react-openapi@1.1.9
## 0.9.1
### Patch Changes
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
- 7d0b422: Handle grouped OpenAPISchemas
- Updated dependencies [7d0b422]
- Updated dependencies [fb90eb0]
- @gitbook/react-openapi@1.1.8
- @gitbook/colors@0.3.1
## 0.9.0
### Minor Changes
- 77fd393: Track event when visitor is opening a search result.
- d70d566: Support site announcement banner
- 77fd393: Track event when clicking announcement banner link.
### Patch Changes
- e84a46a: Fix OpenAPI tabs indicator overflow
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
- 434af90: Fix image resizing when using the proxy feature in a site.
- c756761: Add breadcrumbs to search results
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
- 1505ddb: Fix multiple request examples selector not showing
- 61db166: Add OpenAPI write-only indicator
- 6f71da8: Fix padding in schemas
- fa91eb7: Fix PDF generation when user has dark mode configured.
- 5b1e01c: Support for x-stability property
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
- d236bf0: Fix flash when loading sites with dark mode as default theme
- cd99ed5: Fix spec properties rendering and missing keys
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
- e9fa50d: Trim the search query to avoid showing a loading state when typing
- Updated dependencies [bd35348]
- Updated dependencies [ae78fc5]
- Updated dependencies [7bb37c7]
- Updated dependencies [373183a]
- Updated dependencies [1505ddb]
- Updated dependencies [61db166]
- Updated dependencies [5b1e01c]
- Updated dependencies [cd99ed5]
- Updated dependencies [813b2af]
- Updated dependencies [a25fded]
- @gitbook/react-openapi@1.1.7
- @gitbook/openapi-parser@2.1.2
## 0.8.2 ## 0.8.2
### Patch Changes ### Patch Changes
@@ -610,7 +433,7 @@
- 4cbcc5b: Rollback of scalar modal while fixing perf issue - 4cbcc5b: Rollback of scalar modal while fixing perf issue
- 3996110: Optimize images rendered in community ads - 3996110: Optimize images rendered in community ads
- 133c3e7: Update design of Checkbox to be more consistent and readable - 133c3e7: Update design of Checkbox to be more consistent and readable
- 5096f7f: Disable KV cache for gitbook.com/docs as a test, also disable it for change-request to improve consistency - 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations - 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level - 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected` - b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
+22 -26
View File
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog }, { name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
], ],
}, },
// { {
// name: 'Nexthink', name: 'Nexthink',
// contentBaseURL: 'https://docs.nexthink.com', contentBaseURL: 'https://docs.nexthink.com',
// tests: [ tests: [
// { {
// name: 'Home', name: 'Home',
// url: '/', url: '/',
// screenshot: { waitForTOCScrolling: false }, screenshot: { waitForTOCScrolling: false },
// run: waitForCookiesDialog, run: waitForCookiesDialog,
// }, },
// ], ],
// }, },
{ {
name: 'asiksupport-stg.dto.kemkes.go.id', name: 'asiksupport-stg.dto.kemkes.go.id',
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id', contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
@@ -92,6 +92,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://book.character.ai', contentBaseURL: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
}, },
{
name: 'docs.tradeonnova.io',
contentBaseURL: 'https://docs.tradeonnova.io',
tests: [{ name: 'Home', url: '/' }],
},
{ {
name: 'azcoiner.gitbook.io', name: 'azcoiner.gitbook.io',
contentBaseURL: 'https://azcoiner.gitbook.io', contentBaseURL: 'https://azcoiner.gitbook.io',
@@ -157,11 +162,11 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://wiki.redmodding.org', contentBaseURL: 'https://wiki.redmodding.org',
tests: [{ name: 'Home', url: '/' }], tests: [{ name: 'Home', url: '/' }],
}, },
// { {
// name: 'docs.cherry-ai.com', name: 'docs.cherry-ai.com',
// contentBaseURL: 'https://docs.cherry-ai.com', contentBaseURL: 'https://docs.cherry-ai.com',
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
// }, },
{ {
name: 'docs.snyk.io', name: 'docs.snyk.io',
contentBaseURL: 'https://docs.snyk.io', contentBaseURL: 'https://docs.snyk.io',
@@ -239,15 +244,6 @@ const testCases: TestsCase[] = [
contentBaseURL: 'https://docs.fluentbit.io', contentBaseURL: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }], tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
}, },
{
name: 'run-ai-docs.nvidia.com',
contentBaseURL: 'https://run-ai-docs.nvidia.com',
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
],
},
]; ];
runTestCases(testCases); runTestCases(testCases);
+19 -110
View File
@@ -12,7 +12,7 @@ import {
VISITOR_TOKEN_COOKIE, VISITOR_TOKEN_COOKIE,
getVisitorAuthCookieName, getVisitorAuthCookieName,
getVisitorAuthCookieValue, getVisitorAuthCookieValue,
} from '@/lib/visitors'; } from '@/lib/visitor-token';
import { getSiteAPIToken } from '../tests/utils'; import { getSiteAPIToken } from '../tests/utils';
import { import {
@@ -28,7 +28,6 @@ import {
headerLinks, headerLinks,
runTestCases, runTestCases,
waitForCookiesDialog, waitForCookiesDialog,
waitForNotFound,
} from './util'; } from './util';
const testCases: TestsCase[] = [ const testCases: TestsCase[] = [
@@ -111,24 +110,24 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed', name: 'Customized variant titles are displayed',
url: '', url: '',
run: async (page) => { run: async (page) => {
const spaceDropdown = page const spaceDrowpdown = page
.locator('[data-testid="space-dropdown-button"]') .locator('[data-testid="space-dropdown-button"]')
.locator('visible=true'); .locator('visible=true');
await spaceDropdown.click(); await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator( const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]' 'css=[data-testid="space-dropdown-button"] + div'
); );
// the customized space title // the customized space title
await expect( await expect(
variantSelectionDropdown.getByRole('menuitem', { variantSelectionDropdown.getByRole('link', {
name: 'Multi-Variants', name: 'Multi-Variants',
}) })
).toBeVisible(); ).toBeVisible();
// the NON-customized space title // the NON-customized space title
await expect( await expect(
variantSelectionDropdown.getByRole('menuitem', { variantSelectionDropdown.getByRole('link', {
name: 'RFCs', name: 'RFCs',
}) })
).toBeVisible(); ).toBeVisible();
@@ -145,17 +144,14 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets', url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false, screenshot: false,
run: async (page) => { run: async (page) => {
const spaceDropdown = await page const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]') .locator('[data-testid="space-dropdown-button"]')
.locator('visible=true'); .locator('visible=true');
await spaceDropdown.click(); await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown // Click the second variant in the dropdown
await variantSelectionDropdown await page
.getByRole('menuitem', { .getByRole('link', {
name: '2.0', name: '2.0',
}) })
.click(); .click();
@@ -171,18 +167,14 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets', url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false, screenshot: false,
run: async (page) => { run: async (page) => {
const spaceDropdown = await page const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]') .locator('[data-testid="space-dropdown-button"]')
.locator('visible=true'); .locator('visible=true');
await spaceDropdown.click(); await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown // Click the second variant in the dropdown
await variantSelectionDropdown await page
.getByRole('menuitem', { .getByRole('link', {
name: '2.0', name: '2.0',
}) })
.click(); .click();
@@ -212,18 +204,14 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`; return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
}, },
run: async (page) => { run: async (page) => {
const spaceDropdown = await page const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]') .locator('[data-testid="space-dropdown-button"]')
.locator('visible=true'); .locator('visible=true');
await spaceDropdown.click(); await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator(
'css=[data-testid="dropdown-menu"]'
);
// Click the second variant in the dropdown // Click the second variant in the dropdown
await variantSelectionDropdown await page
.getByRole('menuitem', { .getByRole('link', {
name: '2.0', name: '2.0',
}) })
.click(); .click();
@@ -269,7 +257,7 @@ const testCases: TestsCase[] = [
}, },
{ {
name: 'GitBook', name: 'GitBook',
contentBaseURL: 'https://gitbook.com/docs/', contentBaseURL: 'https://docs.gitbook.com',
tests: [ tests: [
{ {
name: 'Home', name: 'Home',
@@ -320,18 +308,6 @@ const testCases: TestsCase[] = [
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings', url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{
name: 'Invalid revision',
url: '~/revisions/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
{
name: 'Invalid change request',
url: '~/changes/idnotfound/blocks/headings',
run: waitForNotFound,
screenshot: false,
},
], ],
}, },
{ {
@@ -414,59 +390,6 @@ const testCases: TestsCase[] = [
}, },
], ],
}, },
{
name: 'Markdown page',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Text page',
url: 'text-page.md',
screenshot: false,
run: async (_page, response) => {
expect(response?.status()).toBe(200);
expect(response?.headers()['content-type']).toContain('text/markdown');
},
},
],
},
{
name: 'Site subdirectory (proxy)',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://nextjs-gbo-proxy.vercel.app/documentation/',
tests: [
{
name: 'Main',
url: '',
fullPage: true,
},
],
},
{
name: 'Site subdirectory (proxy) with VA',
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
contentBaseURL: 'https://nextjs-gbo-proxy-va.vercel.app/va/docs/',
tests: [
{
name: 'Main',
url: () => {
const privateKey =
'rqSfA6x7eAKx1qDRCDq9aCXwivpUvQ8YkXeDdFvCCUa9QchIcM7pF1iJ4o7AGOU49spmOWjKoIPtX0pVUVQ81w==';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
}
);
return `?jwt_token=${token}`;
},
fullPage: true,
},
],
},
{ {
name: 'Content tests', name: 'Content tests',
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/', contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -885,20 +808,6 @@ const testCases: TestsCase[] = [
}, },
], ],
}, },
{
name: 'Content Redirects',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Redirect to new location',
url: '/content-editor/editing-content/inline/redirect-test',
run: async (page) => {
await expect(page.locator('h1')).toHaveText('Redirect test');
},
screenshot: false,
},
],
},
{ {
name: 'Site Redirects with sections', name: 'Site Redirects with sections',
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/', contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
+38 -78
View File
@@ -16,7 +16,7 @@ import {
type CustomizationThemedColor, type CustomizationThemedColor,
type SiteCustomizationSettings, type SiteCustomizationSettings,
} from '@gitbook/api'; } from '@gitbook/api';
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test'; import { type BrowserContext, type Page, expect, test } from '@playwright/test';
import deepMerge from 'deepmerge'; import deepMerge from 'deepmerge';
import rison from 'rison'; import rison from 'rison';
import type { DeepPartial } from 'ts-essentials'; import type { DeepPartial } from 'ts-essentials';
@@ -33,11 +33,7 @@ export interface Test {
/** /**
* Test to run * Test to run
*/ */
run?: (page: Page, response: Response | null) => Promise<unknown>; run?: (page: Page) => Promise<unknown>;
/**
* Mode for the test.
*/
mode?: 'page' | 'image';
/** /**
* Whether the test should be fullscreened during testing. * Whether the test should be fullscreened during testing.
*/ */
@@ -142,11 +138,6 @@ export async function waitForCookiesDialog(page: Page) {
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
} }
export async function waitForNotFound(_page: Page, response: Response | null) {
expect(response).not.toBeNull();
expect(response?.status()).toBe(404);
}
/** /**
* Transform test cases into Playwright tests and run it. * Transform test cases into Playwright tests and run it.
*/ */
@@ -158,7 +149,6 @@ export function runTestCases(testCases: TestsCase[]) {
test.describe(testCase.name, () => { test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) { for (const testEntry of testCase.tests) {
const { mode = 'page' } = testEntry;
const testFn = testEntry.only ? test.only : test; const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, context }) => { testFn(testEntry.name, async ({ page, context }) => {
const testEntryPathname = const testEntryPathname =
@@ -168,7 +158,6 @@ export function runTestCases(testCases: TestsCase[]) {
new URL(testEntryPathname, testCase.contentBaseURL).toString() new URL(testEntryPathname, testCase.contentBaseURL).toString()
) )
: getTestURL(testEntryPathname); : getTestURL(testEntryPathname);
if (testEntry.cookies) { if (testEntry.cookies) {
await context.addCookies( await context.addCookies(
testEntry.cookies.map((cookie) => ({ testEntry.cookies.map((cookie) => ({
@@ -194,21 +183,13 @@ export function runTestCases(testCases: TestsCase[]) {
} }
}); });
const response = await page.goto(url); await page.goto(url);
if (testEntry.run) { if (testEntry.run) {
await testEntry.run(page, response); await testEntry.run(page);
} }
const screenshotOptions = testEntry.screenshot; const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) { if (screenshotOptions !== false) {
const screenshotName = `${testCase.name} - ${testEntry.name}`; await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
if (mode === 'image') {
await argosScreenshot(page, screenshotName, {
viewports: ['macbook-13'],
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: true,
});
} else {
await argosScreenshot(page, screenshotName, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'], viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: ` argosCSS: `
/* Hide Intercom */ /* Hide Intercom */
@@ -220,14 +201,13 @@ export function runTestCases(testCases: TestsCase[]) {
fullPage: testEntry.fullPage ?? false, fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async ({ runStabilization }) => { beforeScreenshot: async ({ runStabilization }) => {
await runStabilization(); await runStabilization();
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) { if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page); await waitForTOCScrolling(page);
} }
await waitForIcons(page);
}, },
}); });
} }
}
}); });
} }
}); });
@@ -291,9 +271,6 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
internationalization: { internationalization: {
locale: CustomizationLocale.En, locale: CustomizationLocale.En,
}, },
insights: {
trackingCookie: true,
},
favicon: {}, favicon: {},
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
@@ -346,78 +323,61 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
*/ */
async function waitForIcons(page: Page) { async function waitForIcons(page: Page) {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const urlStates: Record< const urls = new Set<string>();
string,
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
> = (window as any).__ICONS_STATES__ || {};
(window as any).__ICONS_STATES__ = urlStates;
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch SVG: ${response.status}`);
}
const svgText = await response.text();
const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22');
return `data:image/svg+xml;charset=utf-8,${encoded}`;
};
const loadUrl = (url: string) => {
// Mark the URL as pending.
urlStates[url] = { state: 'pending', uri: null };
fetchSvgAsDataUri(url).then((uri) => {
urlStates[url] = { state: 'loaded', uri };
});
};
const icons = Array.from(document.querySelectorAll('svg.gb-icon')); const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
const results = icons.map((icon) => { const results = icons.map((icon) => {
if (!(icon instanceof SVGElement)) { if (!(icon instanceof SVGElement)) {
throw new Error('Icon is not an SVGElement'); throw new Error('Icon is not an SVGElement');
} }
// If loaded, good it passes the test.
if (icon.dataset.loadingState === 'loaded') {
return true;
}
// If not loaded yet, we need to load it.
if (icon.dataset.loadingState === 'pending') {
return false;
}
// Ignore icons that are not visible. // Ignore icons that are not visible.
if (!icon.checkVisibility()) { if (!icon.checkVisibility()) {
return true; return true;
} }
const state = icon.getAttribute('data-argos-state');
if (state === 'pending') {
return false;
}
if (state === 'loaded') {
return true;
}
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93") // url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image'); const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/); const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch?.[1]; const url = urlMatch ? urlMatch[1] : null;
// If URL is invalid we throw an error. // If URL is invalid we throw an error.
if (!url) { if (!url) {
throw new Error('No mask-image'); throw new Error('No mask-image');
} }
// If the URL is already queued for loading, we return the state. // If the URL is already loaded, we just mark it as loaded.
if (urlStates[url]) { if (urls.has(url)) {
if (urlStates[url].state === 'loaded') { icon.dataset.loadingState = 'loaded';
icon.setAttribute('data-argos-state', 'pending'); return true;
icon.style.maskImage = `url("${urlStates[url].uri}")`; }
// Mark the icon as pending and load the image.
icon.dataset.loadingState = 'pending';
// Mark the URL as seen.
urls.add(url);
const img = new Image();
img.src = url;
img.decode().then(() => {
// Wait two frames to let the time to the icon to repaint.
requestAnimationFrame(() => { requestAnimationFrame(() => {
icon.setAttribute('data-argos-state', 'loaded'); requestAnimationFrame(() => {
icon.dataset.loadingState = 'loaded';
});
});
}); });
return false;
}
return false;
}
loadUrl(url);
return false; return false;
}); });
-1
View File
@@ -5,7 +5,6 @@ module.exports = {
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL, GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
}, },
webpack(config) { webpack(config) {
+12 -16
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.12.0", "version": "0.8.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev", "dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math" "clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
}, },
"dependencies": { "dependencies": {
"@gitbook/api": "^0.115.0", "@gitbook/api": "*",
"@gitbook/cache-do": "workspace:*", "@gitbook/cache-do": "workspace:*",
"@gitbook/cache-tags": "workspace:*", "@gitbook/cache-tags": "workspace:*",
"@gitbook/colors": "workspace:*", "@gitbook/colors": "workspace:*",
@@ -27,19 +27,19 @@
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-navigation-menu": "^1.2.3", "@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-tooltip": "^1.1.8",
"@sindresorhus/fnv1a": "^3.1.0", "@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"ai": "^4.2.2", "@upstash/redis": "^1.27.1",
"ai": "^4.1.46",
"ajv": "^8.12.0",
"assert-never": "^1.2.1", "assert-never": "^1.2.1",
"bun-types": "^1.1.20", "bun-types": "^1.1.20",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"event-iterator": "^2.0.0",
"framer-motion": "^10.16.14", "framer-motion": "^10.16.14",
"holy-loader": "^2.3.13",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
@@ -47,16 +47,15 @@
"mathjax": "^3.2.2", "mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2", "mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"next": "14.2.26", "next": "14.2.25",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.0", "p-map": "^7.0.0",
"parse-cache-control": "^1.0.1", "parse-cache-control": "^1.0.1",
"partial-json": "^0.1.7", "react": "18.3.1",
"react": "^19.0.0", "react-dom": "18.3.1",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
@@ -70,14 +69,11 @@
"tailwind-shades": "^1.1.2", "tailwind-shades": "^1.1.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"url-join": "^5.0.0", "url-join": "^5.0.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0"
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^5.0.3", "@argos-ci/playwright": "^4.3.0",
"@cloudflare/next-on-pages": "1.13.12", "@cloudflare/next-on-pages": "1.13.7",
"@cloudflare/workers-types": "^4.20241230.0", "@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
-4
View File
@@ -1,7 +1,3 @@
# GitBook immutable static assets # GitBook immutable static assets
# Duplicated from next.config.mjs until OpenNext supports generating static headers
/~gitbook/static/* /~gitbook/static/*
cache-control: public,max-age=31536000,immutable cache-control: public,max-age=31536000,immutable
Access-Control-Allow-Origin: *
/_next/static/*
Access-Control-Allow-Origin: *
@@ -14,15 +14,7 @@ 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) {
let json: JsonBody; const json = (await req.json()) as JsonBody;
try {
json = await req.json();
} catch (err) {
return NextResponse.json({
error: `invalid json body: ${err}`,
});
}
if (!json.tags || !Array.isArray(json.tags)) { if (!json.tags || !Array.isArray(json.tags)) {
return NextResponse.json( return NextResponse.json(
@@ -33,18 +25,10 @@ export async function POST(req: NextRequest) {
); );
} }
try {
const result = await revalidateTags(json.tags); const result = await revalidateTags(json.tags);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
stats: result.stats, stats: result.stats,
}); });
} catch (err: unknown) {
return NextResponse.json(
{
error: `${err}`,
},
{ status: 500 }
);
}
} }
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
export function PageClientLayout(props: { withSections?: boolean }) { export function PageClientLayout(props: { withSections?: boolean }) {
// We use this hook in the page layout to ensure the elements for the blocks // We use this hook in the page layout to ensure the elements for the blocks
// are rendered before we scroll to a hash or to the top of the page // are rendered before we scroll to a hash or to the top of the page
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined }); useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
useStripFallbackQueryParam(); useStripFallbackQueryParam();
return null; return null;
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
import { t, tString, useLanguage } from '@/intl/client'; import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
export default function ErrorPage(props: { export default function ErrorPage(props: {
@@ -35,8 +35,9 @@ export default function ErrorPage(props: {
}} }}
variant="secondary" variant="secondary"
size="small" size="small"
label={tString(language, 'unexpected_error_retry')} >
/> {t(language, 'unexpected_error_retry')}
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,190 +0,0 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useEffect } from 'react';
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Loading } from '../primitives';
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
/**
* Get a unique cache key for a page summary
*/
function getCacheKey(targetSpaceId: string, targetPageId: string): string {
return `${targetSpaceId}:${targetPageId}`;
}
/**
* Global state for the summaries.
*/
const useSummaries = create<{
/**
* Cache of all summaries generated so far.
*/
cache: Map<string, string>;
/**
* Get a summary for a page.
*/
getSummary: (params: { targetSpaceId: string; targetPageId: string }) => string;
/**
* Stream the generation of a summary for a page.
*/
streamSummary: (params: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
visitedPages: { spaceId: string; pageId: string }[];
}) => Promise<void>;
}>((set, get) => ({
cache: new Map(),
getSummary: ({
targetSpaceId,
targetPageId,
}: {
targetSpaceId: string;
targetPageId: string;
}) => {
return get().cache.get(getCacheKey(targetSpaceId, targetPageId)) ?? '';
},
streamSummary: async ({
currentSpaceId,
currentPageId,
currentPageTitle,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
}) => {
const cacheKey = getCacheKey(targetSpaceId, targetPageId);
if (get().cache.has(cacheKey)) {
// Already generated or generating
return;
}
const update = (summary: string) => {
set((prev) => {
const newCache = new Map(prev.cache);
newCache.set(cacheKey, summary);
return { cache: newCache };
});
};
update('');
const stream = await streamLinkPageSummary({
currentSpaceId,
currentPageId,
currentPageTitle,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
let generatedSummary = '';
for await (const highlight of stream) {
generatedSummary = highlight ?? '';
update(generatedSummary);
}
},
}));
/**
* Summarise a page's content for use in a link preview
*/
export function AIPageLinkSummary(props: {
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
showTrademark: boolean;
}) {
const { targetSpaceId, targetPageId, linkPreview, linkTitle, showTrademark = true } = props;
const currentPage = usePageContext();
const language = useLanguage();
const visitedPages = useVisitedPages((state) => state.pages);
const { summary, streamSummary } = useSummaries(
useShallow((state) => {
return {
summary: state.getSummary({ targetSpaceId, targetPageId }),
streamSummary: state.streamSummary,
};
})
);
useEffect(() => {
streamSummary({
currentSpaceId: currentPage.spaceId,
currentPageId: currentPage.pageId,
currentPageTitle: currentPage.title,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
});
}, [
currentPage.pageId,
currentPage.spaceId,
currentPage.title,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
streamSummary,
]);
const shimmerBlocks = [
'w-[20%] [animation-delay:-1s]',
'w-[35%] [animation-delay:-0.8s]',
'w-[25%] [animation-delay:-0.6s]',
'w-[10%] [animation-delay:-0.4s]',
'w-[40%] [animation-delay:-0.2s]',
'w-[30%] [animation-delay:0s]',
];
return (
<div className="flex flex-col gap-1">
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
{showTrademark ? (
<Loading className="size-4" busy={!summary || summary.length === 0} />
) : (
<Icon icon="sparkle" className="size-3" />
)}
<h6 className="text-tint">{t(language, 'link_tooltip_ai_summary')}</h6>
</div>
{summary.length > 0 ? (
<p className="animate-fadeIn">{summary}</p>
) : (
<div className="mt-2 flex flex-wrap gap-2">
{shimmerBlocks.map((block, index) => (
<div
key={`${index}-${block}`}
className={`${block} h-4 animate-pulse rounded straight-corners:rounded-none bg-tint-active`}
/>
))}
</div>
)}
{summary.length > 0 ? (
<div className="animate-fadeIn text-tint-subtle text-xs">
{t(language, 'link_tooltip_ai_summary_description')}
</div>
) : null}
</div>
);
}
@@ -1 +0,0 @@
export * from './AIPageLinkSummary';
@@ -1,124 +0,0 @@
'use server';
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
import type { GitBookBaseContext } from '@v2/lib/context';
import { EventIterator } from 'event-iterator';
import type { MaybePromise } from 'p-map';
import * as partialJson from 'partial-json';
import type { DeepPartial } from 'ts-essentials';
import type { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
/**
* Get the latest value from a stream and the response id.
*/
export async function generate<T>(
promise: MaybePromise<{
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
}>
) {
const input = await promise;
let value: T | undefined;
for await (const event of input.stream) {
value = event;
}
const { responseId } = await input.response;
return {
responseId,
value,
};
}
/**
* Stream the generation of an object using the AI.
*/
export async function streamGenerateObject<T>(
context: GitBookBaseContext,
{
organizationId,
siteId,
}: {
organizationId: string;
siteId: string;
},
{
schema,
messages,
model = AIModel.Fast,
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
}
) {
const rawStream = context.dataFetcher.streamAIResponse({
organizationId,
siteId,
input: messages,
output: {
type: 'object',
schema: zodToJsonSchema(schema),
},
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
return parsed;
}
});
}
/**
* Parse a stream from the API to extract the responseId.
*/
function parseResponse<T>(
responseStream: EventIterator<AIStreamResponse>,
parse: (response: AIStreamResponse) => T | undefined
): {
stream: EventIterator<T>;
response: Promise<{ responseId: string }>;
} {
let resolveResponse: (value: { responseId: string }) => void;
const response = new Promise<{ responseId: string }>((resolve) => {
resolveResponse = resolve;
});
const stream = new EventIterator<T>((queue) => {
(async () => {
let foundResponse = false;
for await (const event of responseStream) {
if (event.type === 'response_finish') {
foundResponse = true;
resolveResponse({ responseId: event.responseId });
} else {
const parsed = parse(event);
if (parsed !== undefined) {
queue.push(parsed);
}
}
}
if (!foundResponse) {
throw new Error('No response found');
}
})().then(
() => {
queue.stop();
},
(error) => {
queue.fail(error);
}
);
});
return { stream, response };
}
@@ -1 +0,0 @@
export * from './streamLinkPageSummary';
@@ -1,166 +0,0 @@
'use server';
import { filterOutNullable } from '@/lib/typescript';
import { getV1BaseContext } from '@/lib/v1';
import { isV2 } from '@/lib/v2';
import { AIMessageRole } from '@gitbook/api';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { getServerActionBaseContext } from '@v2/lib/server-actions';
import { z } from 'zod';
import { streamGenerateObject } from './api';
/**
* Get a summary of a page, in the context of another page
*/
export async function* streamLinkPageSummary({
currentSpaceId,
currentPageId,
targetSpaceId,
targetPageId,
linkPreview,
linkTitle,
visitedPages,
}: {
currentSpaceId: string;
currentPageId: string;
currentPageTitle: string;
targetSpaceId: string;
targetPageId: string;
linkPreview?: string;
linkTitle?: string;
visitedPages?: Array<{ spaceId: string; pageId: string }>;
}) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const { stream } = await streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
highlight: z
.string()
.describe('The reason why the user should read the target page.'),
// questions: z.array(z.string().describe('The questions to sea')).max(3),
}),
messages: [
{
role: AIMessageRole.Developer,
content: `# 1. Role
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
# 2. Task
Extract a contextually-relevant fact that:
- Directly answers the specific need or question implied by the link's placement
- States a capability, limitation, or specification from the target page
- Connects precisely to the user's current paragraph or sentence
- Completes the user's understanding based on what they're currently reading
# 3. Instructions
1. First, identify the exact need, question, or gap in the current paragraph where the link appears
2. Find the specific fact in the target page that addresses this exact contextual need
3. Ensure the fact relates directly to the context of the paragraph containing the link
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
},
{
role: AIMessageRole.Developer,
content: `# 4. Current page
The content of the current page is:`,
attachments: [
{
type: 'page' as const,
spaceId: currentSpaceId,
pageId: currentPageId,
},
],
},
...(visitedPages
? [
{
role: AIMessageRole.Developer,
content: '# 5. Previous pages',
},
...visitedPages.map(({ spaceId, pageId }) => ({
role: AIMessageRole.Developer,
content: `## Page ${pageId}`,
attachments: [
{
type: 'page' as const,
spaceId,
pageId,
},
],
})),
]
: []),
{
role: AIMessageRole.Developer,
content: `# 6. Target page
The content of the target page is:`,
attachments: [
{
type: 'page' as const,
spaceId: targetSpaceId,
pageId: targetPageId,
},
],
},
{
role: AIMessageRole.Developer,
content: `# 7. Link preview
The content of the link preview is:
> ${linkPreview}
> Page ID: ${targetPageId}`,
},
{
role: AIMessageRole.Developer,
content: `# 8. Guidelines & Examples
ALWAYS:
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
- ALWAYS connect target page information specifically to the current paragraph context
- ALWAYS focus on the gap in knowledge that the link is meant to fill
- ALWAYS consider user's navigation history to ensure contextual continuity
- ALWAYS use action verbs like "click", "select", "use", "create", "enable"
NEVER:
- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly.
- NEVER select general facts unrelated to the specific link context
- NEVER ignore the specific context where the link appears
- NEVER repeat the same fact in different words
## Examples
Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once."
Preview: "Multiple Select: Select multiple content blocks at once."
✓ "Shift selects content between two points, useful for reorganizing your current heading structure."
✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks."
Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]."
Preview: "Change Requests: Collaborative content editing workflow."
✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions."
✗ "Each reviewer receives an email notification and can approve or request changes."
Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]."
Preview: "Live Edit: Real-time collaborative editing."
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
},
{
role: AIMessageRole.User,
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
},
].filter(filterOutNullable),
}
);
for await (const value of stream) {
const highlight = value.highlight;
if (!highlight) {
continue;
}
yield highlight;
}
}
@@ -3,10 +3,11 @@
import * as storage from '@/lib/local-storage'; import * as storage from '@/lib/local-storage';
import type { ResolvedContentRef } from '@/lib/references'; import type { ResolvedContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { type CustomizationAnnouncement, SiteInsightsLinkPosition } from '@gitbook/api'; import type { CustomizationAnnouncement } from '@gitbook/api';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import Link from 'next/link';
import { CONTAINER_STYLE } from '../layout'; import { CONTAINER_STYLE } from '../layout';
import { Link, linkStyles } from '../primitives'; import { linkStyles } from '../primitives';
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants'; import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
/** /**
@@ -25,8 +26,7 @@ export function AnnouncementBanner(props: {
const style = BANNER_STYLES[announcement.style]; const style = BANNER_STYLES[announcement.style];
return ( return (
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2"> <div className="announcement-banner scroll-nojump theme-bold:bg-header-background pt-4 pb-2">
<div className="scroll-nojump">
<div className={tcls('relative', CONTAINER_STYLE)}> <div className={tcls('relative', CONTAINER_STYLE)}>
<Tag <Tag
href={contentRef?.href ?? ''} href={contentRef?.href ?? ''}
@@ -36,17 +36,6 @@ export function AnnouncementBanner(props: {
closeable && 'pr-12', closeable && 'pr-12',
hasLink && style.hover hasLink && style.hover
)} )}
insights={
announcement.link
? {
type: 'link_click',
link: {
target: announcement.link.to,
position: SiteInsightsLinkPosition.Announcement,
},
}
: undefined
}
> >
<Icon <Icon
icon={style.icon as IconName} icon={style.icon as IconName}
@@ -57,9 +46,7 @@ export function AnnouncementBanner(props: {
{hasLink ? ( {hasLink ? (
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}> <div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
{contentRef?.icon ? ( {contentRef?.icon ? (
<span className="mr-1 ml-2 *:inline"> <span className="mr-1 ml-2 *:inline">{contentRef?.icon}</span>
{contentRef?.icon}
</span>
) : null} ) : null}
{announcement.link?.title && ( {announcement.link?.title && (
<span className="mr-1">{announcement.link?.title}</span> <span className="mr-1">{announcement.link?.title}</span>
@@ -87,7 +74,6 @@ export function AnnouncementBanner(props: {
) : null} ) : null}
</div> </div>
</div> </div>
</div>
); );
} }
@@ -1,6 +1,5 @@
'use client'; 'use client';
import * as React from 'react';
import { import {
ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_CSS_CLASS,
ANNOUNCEMENT_DAYS_TILL_RESET, ANNOUNCEMENT_DAYS_TILL_RESET,
@@ -19,15 +18,6 @@ export function AnnouncementDismissedScript() {
ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_CSS_CLASS,
]).slice(1, -1); ]).slice(1, -1);
// makes sure the banner dismissed state is kept when navigating between sites
React.useEffect(() => {
checkStorageForDismissedScript(
ANNOUNCEMENT_STORAGE_KEY,
ANNOUNCEMENT_DAYS_TILL_RESET,
ANNOUNCEMENT_CSS_CLASS
);
}, []);
return ( return (
<script <script
suppressHydrationWarning suppressHydrationWarning
@@ -97,8 +97,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => { onClick={() => {
onUpdateState(true); onUpdateState(true);
}} }}
label={tString(language, 'cookies_accept')} >
/> {t(language, 'cookies_accept')}
</Button>
<Button <Button
variant="secondary" variant="secondary"
size="small" size="small"
@@ -106,8 +107,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
onClick={() => { onClick={() => {
onUpdateState(false); onUpdateState(false);
}} }}
label={tString(language, 'cookies_reject')} >
/> {t(language, 'cookies_reject')}
</Button>
</div> </div>
</div> </div>
); );
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
import { List } from './List'; import { List } from './List';
import { ListItem } from './ListItem'; import { ListItem } from './ListItem';
import { BlockMath } from './Math'; import { BlockMath } from './Math';
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI'; import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
import { Paragraph } from './Paragraph'; import { Paragraph } from './Paragraph';
import { Quote } from './Quote'; import { Quote } from './Quote';
import { ReusableContent } from './ReusableContent'; import { ReusableContent } from './ReusableContent';
@@ -85,8 +85,6 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
return <OpenAPIOperation {...props} block={block} />; return <OpenAPIOperation {...props} block={block} />;
case 'openapi-schemas': case 'openapi-schemas':
return <OpenAPISchemas {...props} block={block} />; return <OpenAPISchemas {...props} block={block} />;
case 'openapi-webhook':
return <OpenAPIWebhook {...props} block={block} />;
case 'embed': case 'embed':
return <Embed {...props} block={block} />; return <Embed {...props} block={block} />;
case 'blockquote': case 'blockquote':
@@ -161,7 +159,6 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
case 'swagger': case 'swagger':
case 'openapi-operation': case 'openapi-operation':
case 'openapi-schemas': case 'openapi-schemas':
case 'openapi-webhook':
case 'math': case 'math':
case 'divider': case 'divider':
case 'content-ref': case 'content-ref':
@@ -26,7 +26,6 @@ export function ClientCodeBlock(props: ClientBlockProps) {
const [isInViewport, setIsInViewport] = useState(false); const [isInViewport, setIsInViewport] = useState(false);
const plainLines = useMemo(() => plainHighlight(block, []), [block]); const plainLines = useMemo(() => plainHighlight(block, []), [block]);
const [lines, setLines] = useState<null | HighlightLine[]>(null); const [lines, setLines] = useState<null | HighlightLine[]>(null);
const [highlighting, setHighlighting] = useState(false);
// Preload the highlighter when the block is mounted. // Preload the highlighter when the block is mounted.
useEffect(() => { useEffect(() => {
@@ -78,7 +77,6 @@ export function ClientCodeBlock(props: ClientBlockProps) {
let cancelled = false; let cancelled = false;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setHighlighting(true);
import('./highlight').then(({ highlight }) => { import('./highlight').then(({ highlight }) => {
highlight(block, inlines).then((lines) => { highlight(block, inlines).then((lines) => {
if (cancelled) { if (cancelled) {
@@ -86,7 +84,6 @@ export function ClientCodeBlock(props: ClientBlockProps) {
} }
setLines(lines); setLines(lines);
setHighlighting(false);
}); });
}); });
} }
@@ -101,12 +98,6 @@ export function ClientCodeBlock(props: ClientBlockProps) {
}, [isInViewport, block, inlines]); }, [isInViewport, block, inlines]);
return ( return (
<CodeBlockRenderer <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines ?? plainLines} />
ref={blockRef}
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
/>
); );
} }
@@ -24,7 +24,7 @@
} }
.highlight-line-number { .highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent; @apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
@apply before:text-tint before:content-[counter(line)]; @apply before:text-tint before:content-[counter(line)];
.highlight-line.highlighted > & { .highlight-line.highlighted > & {
@@ -14,7 +14,6 @@ import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & { type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[]; lines: HighlightLine[];
'aria-busy'?: boolean;
}; };
/** /**
@@ -24,7 +23,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps, props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement> ref: React.ForwardedRef<HTMLDivElement>
) { ) {
const { block, style, lines, 'aria-busy': ariaBusy } = props; const { block, style, lines } = props;
const id = useId(); const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1; const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
@@ -32,14 +31,10 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
const title = block.data.title; const title = block.data.title;
return ( return (
<div <div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
ref={ref}
aria-busy={ariaBusy}
className={tcls('group/codeblock grid grid-flow-col', style)}
>
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]"> <div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
{title ? ( {title ? (
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base"> <div className="inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s bg-tint px-3 py-2 text-tint text-xs leading-none tracking-wide">
{title} {title}
</div> </div>
) : null} ) : null}
@@ -50,15 +45,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
/> />
<pre <pre
className={tcls( className={tcls(
'hide-scroll relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base', 'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]',
'rounded-md straight-corners:rounded-sm shadow-sm', 'rounded-md straight-corners:rounded-sm',
title && 'rounded-ss-none' title && 'rounded-ss-none'
)} )}
> >
<code <code
id={id} id={id}
className={tcls( className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap', 'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line]',
withWrap && 'whitespace-pre-wrap' withWrap && 'whitespace-pre-wrap'
)} )}
> >

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