mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-27 15:37:05 +00:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36877d92a6 | |||
| 23433d1287 | |||
| 5d9183e1b4 | |||
| a1d5326ffa | |||
| cc26fb34aa | |||
| b1abbf603b | |||
| a70e8cd7dc | |||
| b38eed850f | |||
| f4c50c7e3c | |||
| 5d308c764e | |||
| 08e780496b | |||
| e4cd67ebe2 | |||
| 939b160bfa | |||
| 689e78d8e7 | |||
| 37c1d4b8ea | |||
| 75855585f5 | |||
| dcf0f9aac5 | |||
| 1d7253307a | |||
| 2b6c593289 | |||
| c0b339e406 | |||
| da485f5144 | |||
| 139a8050a5 | |||
| da7b369ffd | |||
| 432da64919 | |||
| 7d0b422200 | |||
| fb90eb0acc | |||
| b647538e41 | |||
| e50797a5c1 | |||
| 5b1e01c786 | |||
| 813b2af06b | |||
| 6f71da88d8 | |||
| fa91eb7eaf | |||
| c756761591 | |||
| 434af90366 | |||
| 0f41e194cb | |||
| bd353489e9 | |||
| 77fd393f15 | |||
| 8701b5e078 | |||
| 61db166f92 | |||
| 0d9b9cea0f | |||
| 7bb37c7f0a | |||
| 5b2bf828e3 | |||
| c85d3f9752 | |||
| 4d7088bbf0 | |||
| dd6ab6044c | |||
| d236bf029c | |||
| d70d566abe | |||
| a25fdedea9 | |||
| 373183a2cd | |||
| e9fa50dfb5 | |||
| 57ca4e0e88 | |||
| bc90adb73a | |||
| 1505ddbd5a | |||
| cd99ed57fb | |||
| 27de1cd01d | |||
| ae78fc5394 | |||
| 40e8e69c6d | |||
| e84a46a558 | |||
| 18a72d4466 | |||
| d7aa5fa003 | |||
| 27532a5f41 | |||
| 48c18c03b9 | |||
| bba2e52e24 | |||
| 54ee0149e1 | |||
| 8fda9d2fab | |||
| 77a72d79ce | |||
| 73e2b472ef | |||
| 2f28cc1323 | |||
| f5516d4e21 | |||
| d2facb2db1 | |||
| 6eae764b7c | |||
| 970ef8f886 | |||
| ed0720699e | |||
| ad94d00d5f | |||
| c7843f7f52 | |||
| 1fe3286bdb | |||
| ce030fdf76 | |||
| 9ba8783952 | |||
| 2f3966cc53 | |||
| 8f65a22e13 | |||
| 2787d884cd | |||
| fc00b51683 | |||
| f80c3a7b37 | |||
| 4e5d6c7487 | |||
| 96f84b5917 | |||
| 6b8a5fdd1b | |||
| 89083ac790 | |||
| 70be2c6b2b | |||
| bcf877841b | |||
| a84b06bb19 | |||
| 24b7808d9a | |||
| 8364ee988e | |||
| 07f6e2d233 | |||
| 7212973f34 | |||
| af44e0436b | |||
| 3f9c35d505 | |||
| 2c13f8b95c | |||
| 4dab1c56e5 | |||
| 4723f03ef1 | |||
| 278689e0a0 | |||
| 886e204e68 | |||
| c59947ab39 | |||
| 4f0a772a21 | |||
| 903f962673 | |||
| 31d800e48b | |||
| c1c58eb919 | |||
| eec3eed100 | |||
| 99da8df3d1 | |||
| c9ea23919e | |||
| 16292def79 | |||
| 9108c56ca1 | |||
| b011ea0af8 | |||
| d33171b779 | |||
| 6aaeae269e | |||
| e5f0cc8663 | |||
| 9b5f971aa1 | |||
| ff3b708ad3 | |||
| e996904013 | |||
| f32bf1f15d | |||
| 6cda2dd57b | |||
| c60e9ba27f | |||
| 9bc3d504c8 | |||
| b5e4fd75e9 | |||
| a92cb49445 | |||
| 88f64b0d63 | |||
| 844059f9a5 | |||
| eaf2d68308 | |||
| 12bdbf988d | |||
| 29bab79005 | |||
| 8d5de97ddf | |||
| d9b5a898ab | |||
| f127d28675 | |||
| 65c1bdc455 | |||
| 0711a171d4 | |||
| c58c551f9e | |||
| f574858b9b | |||
| 3e1592e16b |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@gitbook/openapi-parser': patch
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Remove stable from x-stability
|
||||
@@ -1,6 +1,12 @@
|
||||
name: 'Deploy cloudflare'
|
||||
description: 'Deploy GitBook to Cloudflare'
|
||||
inputs:
|
||||
opItem:
|
||||
description: '1Password item to load secrets from'
|
||||
required: true
|
||||
opServiceAccount:
|
||||
description: '1Password service account token'
|
||||
required: true
|
||||
apiToken:
|
||||
description: 'Cloudflare API token'
|
||||
required: true
|
||||
@@ -8,44 +14,11 @@ inputs:
|
||||
description: 'Cloudflare account ID'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
description: 'Cloudflare environment to deploy to (staging, production, preview)'
|
||||
required: true
|
||||
deploy:
|
||||
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||
required: true
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY:
|
||||
description: 'Next server actions encryption key'
|
||||
required: true
|
||||
GITBOOK_URL:
|
||||
description: 'GitBook URL'
|
||||
required: true
|
||||
GITBOOK_SECRET:
|
||||
description: 'GitBook secret'
|
||||
required: true
|
||||
GITBOOK_APP_URL:
|
||||
description: 'GitBook app URL'
|
||||
required: false
|
||||
GITBOOK_API_URL:
|
||||
description: 'GitBook API URL'
|
||||
required: false
|
||||
GITBOOK_INTEGRATIONS_HOST:
|
||||
description: 'GitBook integrations host'
|
||||
required: false
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY:
|
||||
description: 'GitBook image resize signing key'
|
||||
required: true
|
||||
GITBOOK_IMAGE_RESIZE_URL:
|
||||
description: 'GitBook image resize URL'
|
||||
required: true
|
||||
GITBOOK_ICONS_URL:
|
||||
description: 'GitBook icons URL'
|
||||
required: true
|
||||
GITBOOK_ICONS_TOKEN:
|
||||
description: 'GitBook icons token'
|
||||
required: true
|
||||
GITBOOK_ASSETS_PREFIX:
|
||||
description: 'GitBook assets prefix'
|
||||
required: false
|
||||
outputs:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
@@ -60,21 +33,27 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v2
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build worker
|
||||
run: bun run turbo build:v2:cloudflare
|
||||
shell: bash
|
||||
env:
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
GITBOOK_URL: ${{ inputs.GITBOOK_URL }}
|
||||
GITBOOK_SECRET: ${{ inputs.GITBOOK_SECRET }}
|
||||
GITBOOK_APP_URL: ${{ inputs.GITBOOK_APP_URL }}
|
||||
GITBOOK_API_URL: ${{ inputs.GITBOOK_API_URL }}
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.GITBOOK_INTEGRATIONS_HOST }}
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.GITBOOK_IMAGE_RESIZE_SIGNING_KEY }}
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.GITBOOK_IMAGE_RESIZE_URL }}
|
||||
GITBOOK_ICONS_URL: ${{ inputs.GITBOOK_ICONS_URL }}
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.GITBOOK_ICONS_TOKEN }}
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.GITBOOK_ASSETS_PREFIX }}
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3.14.0
|
||||
@@ -84,7 +63,7 @@ runs:
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '3.112.0'
|
||||
environment: ${{ inputs.environment }}
|
||||
command: ${{ inputs.deploy && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
|
||||
command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
name: 'Deploy vercel'
|
||||
description: 'Deploy GitBook to Vercel'
|
||||
inputs:
|
||||
vercel-org:
|
||||
vercelOrg:
|
||||
description: 'Vercel organization'
|
||||
required: true
|
||||
vercel-project:
|
||||
vercelProject:
|
||||
description: 'Vercel project'
|
||||
required: true
|
||||
vercel-token:
|
||||
vercelToken:
|
||||
description: 'Vercel token'
|
||||
required: true
|
||||
opItem:
|
||||
description: '1Password item to load secrets from'
|
||||
required: true
|
||||
opServiceAccount:
|
||||
description: '1Password service account token'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Environment to deploy to'
|
||||
required: true
|
||||
@@ -27,32 +33,46 @@ runs:
|
||||
shell: bash
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Sets env vars for environment
|
||||
shell: bash
|
||||
run: |
|
||||
echo "VERCEL_ENVIRONMENT=${{ inputs.environment }}" >> $GITHUB_ENV
|
||||
- name: Pull Vercel Environment Information
|
||||
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }}
|
||||
run: bun run vercel pull --yes --environment=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
- name: Load secret
|
||||
uses: 1password/load-secrets-action@v2
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
|
||||
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
|
||||
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
|
||||
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
|
||||
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
|
||||
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
|
||||
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
|
||||
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
|
||||
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build Project Artifacts
|
||||
run: bun run vercel build --target=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }}
|
||||
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
|
||||
shell: bash
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
shell: bash
|
||||
run: |
|
||||
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=$VERCEL_ENVIRONMENT --token=${{ inputs.vercel-token }})
|
||||
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }})
|
||||
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ inputs.vercel-org }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercel-project }}
|
||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
|
||||
|
||||
@@ -3,15 +3,6 @@ description: 'Install Bun and cache dependencies'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
|
||||
# and impacting bandwidth there.
|
||||
- name: Cache bun
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
@@ -10,6 +10,9 @@ jobs:
|
||||
deploy-v1-cloudflare:
|
||||
name: Deploy v1 to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
@@ -32,21 +35,11 @@ jobs:
|
||||
- name: Sets env vars for production
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
|
||||
- name: Sets env vars for preview
|
||||
if: github.ref != 'refs/heads/main'
|
||||
run: |
|
||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||
- name: Build Next.js with next-on-pages
|
||||
run: bun run turbo gitbook#build:cloudflare
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
SENTRY_RELEASE: ${{ github.sha }}
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3.14.0
|
||||
@@ -63,56 +56,45 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (preview)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
statuses: write
|
||||
environment:
|
||||
name: 2v-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Deploy ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
|
||||
- name: Deploy to Vercel
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
|
||||
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
environment: preview
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-preview
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (preview)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
statuses: write
|
||||
environment:
|
||||
name: 2c-preview
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Deploy ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
|
||||
- name: Deploy to Cloudflare
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-cloudflare
|
||||
with:
|
||||
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'preview' }}
|
||||
environment: preview
|
||||
deploy: ${{ github.ref == 'refs/heads/main' }}
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
GITBOOK_URL: ${{ github.ref == 'refs/heads/main' && vars.PRODUCTION_2C_GITBOOK_URL || vars.PREVIEW_2C_GITBOOK_URL }}
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ secrets.PREVIEW_GITBOOK_IMAGE_RESIZE_SIGNING_KEY }}
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ vars.GITBOOK_IMAGE_RESIZE_URL }}
|
||||
GITBOOK_ICONS_URL: ${{ vars.GITBOOK_ICONS_URL }}
|
||||
GITBOOK_ICONS_TOKEN: ${{ vars.GITBOOK_ICONS_TOKEN }}
|
||||
GITBOOK_SECRET: ${{ github.ref == 'refs/heads/main' && secrets.PRODUCTION_GITBOOK_SECRET|| '' }}
|
||||
opItem: op://gitbook-open/2c-preview
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
@@ -198,7 +180,8 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'v2-vercel'
|
||||
visual-testing-customers-v1:
|
||||
@@ -238,7 +221,8 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e-customers
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: 'customers-v2'
|
||||
pagespeed-testing-v1:
|
||||
@@ -255,7 +239,7 @@ jobs:
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run pagespeed tests
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{needs.deploy-v1-cloudflare.outputs.deployment-url}}
|
||||
BASE_URL: ${{needs.deploy-v1-cloudflare.outputs.deployment-url}}
|
||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||
|
||||
@@ -9,6 +9,9 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (production)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: 2v-production
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
@@ -18,13 +21,18 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: production
|
||||
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
environment: production
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (production)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: 2c-production
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
@@ -34,16 +42,12 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-cloudflare
|
||||
with:
|
||||
environment: staging
|
||||
environment: production
|
||||
deploy: true
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
GITBOOK_URL: ${{ vars.STAGING_2C_GITBOOK_URL }}
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ secrets.STAGING_GITBOOK_IMAGE_RESIZE_SIGNING_KEY }}
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ vars.GITBOOK_IMAGE_RESIZE_URL }}
|
||||
GITBOOK_ICONS_URL: ${{ vars.GITBOOK_ICONS_URL }}
|
||||
GITBOOK_ICONS_TOKEN: ${{ vars.GITBOOK_ICONS_TOKEN }}
|
||||
GITBOOK_ASSETS_PREFIX: ${{ vars.STAGING_2C_GITBOOK_ASSETS_PREFIX }}
|
||||
opItem: op://gitbook-open/2c-production
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
@@ -9,6 +9,9 @@ jobs:
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel (staging)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: 2v-staging
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
@@ -18,13 +21,18 @@ jobs:
|
||||
id: deploy
|
||||
uses: ./.github/composite/deploy-vercel
|
||||
with:
|
||||
environment: staging
|
||||
vercel-org: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercel-project: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
environment: staging
|
||||
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
|
||||
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
vercelToken: ${{ secrets.VERCEL_TOKEN }}
|
||||
opItem: op://gitbook-open/2v-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker (staging)
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: 2c-staging
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
@@ -38,17 +46,8 @@ jobs:
|
||||
deploy: true
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
GITBOOK_URL: ${{ vars.STAGING_2C_GITBOOK_URL }}
|
||||
GITBOOK_SECRET: ${{ secrets.STAGING_GITBOOK_SECRET }}
|
||||
GITBOOK_APP_URL: https://app.gitbook-staging.com
|
||||
GITBOOK_API_URL: https://api.gitbook-staging.com
|
||||
GITBOOK_INTEGRATIONS_HOST: https://integrations.gitbook-staging.com
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ secrets.STAGING_GITBOOK_IMAGE_RESIZE_SIGNING_KEY }}
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ vars.GITBOOK_IMAGE_RESIZE_URL }}
|
||||
GITBOOK_ICONS_URL: ${{ vars.GITBOOK_ICONS_URL }}
|
||||
GITBOOK_ICONS_TOKEN: ${{ vars.GITBOOK_ICONS_TOKEN }}
|
||||
GITBOOK_ASSETS_PREFIX: ${{ vars.STAGING_2C_GITBOOK_ASSETS_PREFIX }}
|
||||
opItem: op://gitbook-open/2c-staging
|
||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.next
|
||||
.vercel
|
||||
.open-next
|
||||
|
||||
# Generated
|
||||
packages/emoji-codepoints/index.ts
|
||||
packages/gitbook/public/~gitbook/static/
|
||||
packages/icons/src/data/*.json
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
|
||||
# v2
|
||||
packages/gitbook-v2/public/~gitbook/static/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4
|
||||
}
|
||||
Vendored
+3
@@ -13,5 +13,8 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@
|
||||
"**/.vercel/**/*",
|
||||
"**/.cache/**/*",
|
||||
"**/.wrangler/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*"
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
+6
-2
@@ -7,11 +7,12 @@
|
||||
"turbo": "^2.4.4",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.4",
|
||||
"packageManager": "bun@1.2.5",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"@gitbook/api": "0.107.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -37,5 +38,8 @@
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tooltip": "^1.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# @gitbook/cache-tags
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f32bf1f: Export function `getCacheTagForURL` to easily get the cache tag for a URL.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 05ffd0e: Initial version of the package
|
||||
- 05ffd0e: Initial version of the package
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "0.96.1",
|
||||
"@gitbook/api": "*",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -60,7 +60,7 @@ export function getCacheTag(
|
||||
| {
|
||||
tag: 'computed-document';
|
||||
space: string;
|
||||
integration: string;
|
||||
sourceType: string;
|
||||
}
|
||||
/**
|
||||
* All data related to the URL of a content
|
||||
@@ -84,6 +84,14 @@ export function getCacheTag(
|
||||
organization: string;
|
||||
openAPISpec: string;
|
||||
}
|
||||
/**
|
||||
* All data related to a translation
|
||||
*/
|
||||
| {
|
||||
tag: 'translation';
|
||||
organization: string;
|
||||
translationSettings: string;
|
||||
}
|
||||
): string {
|
||||
switch (spec.tag) {
|
||||
case 'user':
|
||||
@@ -99,18 +107,31 @@ export function getCacheTag(
|
||||
case 'document':
|
||||
return `space:${spec.space}:document:${spec.document}`;
|
||||
case 'computed-document':
|
||||
return `space:${spec.space}:computed-document:${spec.integration}`;
|
||||
return `space:${spec.space}:computed-document:${spec.sourceType}`;
|
||||
case 'site':
|
||||
return `site:${spec.site}`;
|
||||
case 'integration':
|
||||
return `integration:${spec.integration}`;
|
||||
case 'openapi':
|
||||
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
|
||||
case 'translation':
|
||||
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
|
||||
default:
|
||||
assertNever(spec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache tag for a given URL.
|
||||
*/
|
||||
export function getCacheTagForURL(url: string | URL) {
|
||||
const parsedURL = url instanceof URL ? url : new URL(url);
|
||||
return getCacheTag({
|
||||
tag: 'url',
|
||||
hostname: parsedURL.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags for a computed content source.
|
||||
*/
|
||||
@@ -146,6 +167,15 @@ export function getComputedContentSourceCacheTags(
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'translation-language':
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'translation',
|
||||
organization: inContext.organizationId,
|
||||
translationSettings: dependency.ref.translationSettings,
|
||||
})
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Do not throw for unknown dependency types
|
||||
// as it might mean we are lacking behind the API version
|
||||
@@ -158,18 +188,22 @@ export function getComputedContentSourceCacheTags(
|
||||
getCacheTag({
|
||||
tag: 'computed-document',
|
||||
space: inContext.spaceId,
|
||||
integration: source.integration,
|
||||
sourceType: source.type,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// We invalidate the computed content when a new version of the integration is deployed.
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
integration: source.integration,
|
||||
})
|
||||
);
|
||||
|
||||
if (source.type.startsWith('integration:')) {
|
||||
const integration = source.type.split(':')[1];
|
||||
tags.push(
|
||||
getCacheTag({
|
||||
tag: 'integration',
|
||||
integration,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
# @gitbook/colors
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fb90eb0: Reduce chroma of first color scale step
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4f0a772: Override tint lightness if supplied color is out of bounds
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 445baaa: Initial release
|
||||
- 445baaa: Initial release
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
|
||||
@@ -184,6 +184,7 @@ export function colorScale(
|
||||
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
|
||||
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
|
||||
const backgroundColor = rgbToOklch(hexToRgbArray(background));
|
||||
let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
|
||||
|
||||
if (mixColor && mix?.ratio && mix.ratio > 0) {
|
||||
// If defined, we mix in a (tiny) bit of the mix color with the base color.
|
||||
@@ -192,7 +193,20 @@ export function colorScale(
|
||||
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
|
||||
}
|
||||
|
||||
const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
|
||||
if (
|
||||
(darkMode && baseColor.L < backgroundColor.L) ||
|
||||
(!darkMode && baseColor.L > backgroundColor.L)
|
||||
) {
|
||||
// If the supplied color is outside of our lightness bounds, use the supplied color's lightness.
|
||||
// This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look.
|
||||
const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L;
|
||||
backgroundColor.L = baseColor.L;
|
||||
// At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out.
|
||||
const amplifier = 1;
|
||||
mapping = mapping.map((step, index) =>
|
||||
index < 9 ? step + step * amplifier * difference : step
|
||||
);
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
@@ -206,7 +220,7 @@ export function colorScale(
|
||||
continue;
|
||||
}
|
||||
|
||||
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
|
||||
const chromaRatio = index < 8 ? index * 0.05 : 1;
|
||||
|
||||
const shade = {
|
||||
L: targetL, // Blend lightness
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
# gitbook-v2
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 54ee014: Add initial support for loading custom fonts
|
||||
- bba2e52: Fix site redirects when it includes a section/variant path
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f32bf1f]
|
||||
- @gitbook/cache-tags@0.2.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 76c7974: Add route to revalidate cached data
|
||||
- 76c7974: Add route to revalidate cached data
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 05ffd0e: Improving data cache management for computed content
|
||||
- Updated dependencies [05ffd0e]
|
||||
- @gitbook/cache-tags@0.1.0
|
||||
- 05ffd0e: Improving data cache management for computed content
|
||||
- Updated dependencies [05ffd0e]
|
||||
- @gitbook/cache-tags@0.1.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3e11678: fix: lost section groups
|
||||
- 3e11678: fix: lost section groups
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cfccc44: Setup structure and deployment for new version
|
||||
- cfccc44: Setup structure and deployment for new version
|
||||
|
||||
@@ -5,21 +5,37 @@
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
|
||||
// This is needed to use 'use cache'
|
||||
useCache: true,
|
||||
|
||||
// Content is fully static, we can cache it in the session memory cache for a long time
|
||||
staleTimes: {
|
||||
dynamic: 3600, // 1 hour
|
||||
static: 3600, // 1 hour
|
||||
},
|
||||
},
|
||||
|
||||
env: {
|
||||
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
GITBOOK_URL: process.env.GITBOOK_URL,
|
||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||
|
||||
// GitBook envs
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
GITBOOK_URL: process.env.GITBOOK_URL,
|
||||
GITBOOK_API_TOKEN: process.env.GITBOOK_API_TOKEN,
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
|
||||
// Next.js envs
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
|
||||
// Used to detect if the app is running in V2 mode
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"next": "^15.2.1",
|
||||
"next": "^15.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@gitbook/api": "0.96.1",
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"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": {
|
||||
"gitbook": "*",
|
||||
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d",
|
||||
"@opennextjs/cloudflare": "^0.5.10",
|
||||
"@types/rison": "^0.0.9",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8"
|
||||
},
|
||||
@@ -26,6 +30,7 @@
|
||||
"start": "next start",
|
||||
"build:v2:cloudflare": "opennextjs-cloudflare",
|
||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
||||
"unit": "bun test"
|
||||
"unit": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-7
@@ -13,26 +13,24 @@ type PageProps = {
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const context = await getDynamicSiteContext(params);
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
|
||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const context = await getDynamicSiteContext(await props.params);
|
||||
const { context } = await getDynamicSiteContext(await props.params);
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
|
||||
const context = await getDynamicSiteContext(params);
|
||||
const params = await props.params;
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return generateSitePageMetadata({
|
||||
context,
|
||||
pageParams: { pathname },
|
||||
redirectOnFallback: true,
|
||||
fallback: !!searchParams.fallback,
|
||||
});
|
||||
}
|
||||
+6
-7
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/components/SiteLayout';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||
|
||||
interface SiteDynamicLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -16,17 +16,16 @@ export default async function SiteDynamicLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
@@ -35,11 +34,11 @@ export default async function SiteDynamicLayout({
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveIcon(context, request);
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveOGImage(context, await params);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { PDFRootLayout } from '@/components/PDF';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
|
||||
export default async function RootLayout(props: {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { params, children } = props;
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
|
||||
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { PDFPage, generatePDFMetadata } from '@/components/PDF';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}) {
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return generatePDFMetadata(context);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const { params, searchParams } = props;
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return <PDFPage context={context} searchParams={await searchParams} />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { serveLLMsTxt } from '@/routes/llms';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { serveRobotsTxt } from '@/routes/robots';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveRobotsTxt(context);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { servePagesSitemap } from '@/routes/sitemap';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return servePagesSitemap(context);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { serveRootSitemap } from '@/routes/sitemap';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
return serveRootSitemap(context);
|
||||
}
|
||||
+4
-5
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
|
||||
'use cache';
|
||||
|
||||
const params = await props.params;
|
||||
const context = await getStaticSiteContext(params);
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
cacheTag(
|
||||
@@ -28,22 +28,21 @@ export default async function Page(props: PageProps) {
|
||||
})
|
||||
);
|
||||
|
||||
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
|
||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const context = await getStaticSiteContext(await props.params);
|
||||
const { context } = await getStaticSiteContext(await props.params);
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const context = await getStaticSiteContext(params);
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return generateSitePageMetadata({
|
||||
context,
|
||||
pageParams: { pathname },
|
||||
redirectOnFallback: true,
|
||||
});
|
||||
}
|
||||
+4
-4
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
'use cache';
|
||||
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
@@ -33,7 +33,7 @@ export default async function SiteStaticLayout({
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
visitorAuthToken={null}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
@@ -42,11 +42,11 @@ export default async function SiteStaticLayout({
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
+2
-2
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context, { withMarkdownPages: true });
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveRobotsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return servePagesSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveRootSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveIcon(context, request);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { servePageMarkdown } from '@/routes/markdownPage';
|
||||
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
const pathname = getPagePathFromParams(await params);
|
||||
return servePageMarkdown(context, pathname);
|
||||
}
|
||||
+1
-1
@@ -10,6 +10,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveOGImage(context, await params);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
fetchSiteContextByURL,
|
||||
fetchSiteContextByURLLookup,
|
||||
getBaseContext,
|
||||
} from '@v2/lib/context';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||
import type { SiteAPIToken } from '@gitbook/api';
|
||||
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { forbidden } from 'next/navigation';
|
||||
import rison from 'rison';
|
||||
|
||||
export type RouteParamMode = 'url-host' | 'url';
|
||||
|
||||
@@ -12,6 +12,9 @@ export type RouteLayoutParams = {
|
||||
|
||||
/** URL encoded site URL */
|
||||
siteURL: string;
|
||||
|
||||
/** URL and Rison encoded site data from getPublishedContentByUrl */
|
||||
siteData: string;
|
||||
};
|
||||
|
||||
export type RouteParams = RouteLayoutParams & {
|
||||
@@ -21,19 +24,30 @@ export type RouteParams = RouteLayoutParams & {
|
||||
/**
|
||||
* Get the static context when rendering statically a site.
|
||||
*/
|
||||
export function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
export async function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
return fetchSiteContextByURL(
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
// For static routes, we check the expiration of the JWT token
|
||||
// as the route might be revalidated after expiration
|
||||
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
|
||||
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
|
||||
forbidden();
|
||||
}
|
||||
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
}),
|
||||
{
|
||||
url: siteURL.toString(),
|
||||
visitorAuthToken: null,
|
||||
redirectOnError: false,
|
||||
}
|
||||
siteURLData
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,15 +56,21 @@ export function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
*/
|
||||
export async function getDynamicSiteContext(params: RouteLayoutParams) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
return fetchSiteContextByURLLookup(
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
}),
|
||||
siteURLData
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,3 +94,11 @@ function getModeFromParams(mode: string): RouteParamMode {
|
||||
|
||||
return 'url';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decoded site data from the params.
|
||||
*/
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||
const decoded = decodeURIComponent(params.siteData);
|
||||
return rison.decode(decoded);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import {
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_API_TOKEN,
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_SECRET,
|
||||
GITBOOK_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
} from '@v2/lib/env';
|
||||
@@ -18,9 +24,17 @@ export async function GET(_req: NextRequest) {
|
||||
GITBOOK_URL,
|
||||
GITBOOK_APP_URL,
|
||||
GITBOOK_API_URL,
|
||||
GITBOOK_API_PUBLIC_URL,
|
||||
GITBOOK_ASSETS_URL,
|
||||
GITBOOK_FONTS_URL,
|
||||
GITBOOK_ICONS_URL,
|
||||
GITBOOK_USER_AGENT,
|
||||
GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_DISABLE_TRACKING,
|
||||
|
||||
// Secret envs
|
||||
GITBOOK_SECRET: !!GITBOOK_SECRET,
|
||||
GITBOOK_API_TOKEN: !!GITBOOK_API_TOKEN,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: !!GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type GitBookBaseContext,
|
||||
type GitBookSpaceContext,
|
||||
fetchSpaceContextByIds,
|
||||
} from '@v2/lib/context';
|
||||
import { createDataFetcher } from '@v2/lib/data';
|
||||
import { createLinker } from '@v2/lib/links';
|
||||
import { getAPITokenFromMiddleware } from '@v2/lib/middleware';
|
||||
|
||||
export type SpacePDFRouteParams = {
|
||||
spaceId: string;
|
||||
changeRequestId?: string;
|
||||
revisionId?: string;
|
||||
};
|
||||
|
||||
export async function getSpacePDFContext(
|
||||
params: SpacePDFRouteParams
|
||||
): Promise<GitBookSpaceContext> {
|
||||
const { spaceId } = params;
|
||||
|
||||
const apiToken = await getAPITokenFromMiddleware();
|
||||
|
||||
const basePath = getPDFRoutePath(params);
|
||||
const linker = createLinker({
|
||||
spaceBasePath: basePath,
|
||||
siteBasePath: basePath,
|
||||
});
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: apiToken,
|
||||
});
|
||||
|
||||
const baseContext: GitBookBaseContext = {
|
||||
linker,
|
||||
dataFetcher,
|
||||
};
|
||||
|
||||
return await fetchSpaceContextByIds(baseContext, {
|
||||
space: spaceId,
|
||||
shareKey: undefined,
|
||||
changeRequest: params.changeRequestId,
|
||||
revision: params.revisionId,
|
||||
});
|
||||
}
|
||||
|
||||
function getPDFRoutePath(params: SpacePDFRouteParams) {
|
||||
let path = `/~space/${params.spaceId}`;
|
||||
|
||||
if (params.changeRequestId) {
|
||||
path += `/~/changes/${params.changeRequestId}`;
|
||||
}
|
||||
|
||||
if (params.revisionId) {
|
||||
path += `/~/revisions/${params.revisionId}`;
|
||||
}
|
||||
|
||||
path += '~gitbook/pdf';
|
||||
|
||||
return path;
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
|
||||
export default RootLayout;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
|
||||
|
||||
export default PDFPage;
|
||||
export { generateMetadata };
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
|
||||
export default RootLayout;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
|
||||
|
||||
export default PDFPage;
|
||||
export { generateMetadata };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PDFRootLayout } from '@/components/PDF';
|
||||
import { type SpacePDFRouteParams, getSpacePDFContext } from '@v2/app/~space/[spaceId]/pdf';
|
||||
|
||||
export default async function RootLayout(props: {
|
||||
params: Promise<SpacePDFRouteParams>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { params, children } = props;
|
||||
const context = await getSpacePDFContext(await params);
|
||||
|
||||
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PDFPage, generatePDFMetadata } from '@/components/PDF';
|
||||
import { type SpacePDFRouteParams, getSpacePDFContext } from '@v2/app/~space/[spaceId]/pdf';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<SpacePDFRouteParams>;
|
||||
}) {
|
||||
const context = await getSpacePDFContext(await params);
|
||||
return generatePDFMetadata(context);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<SpacePDFRouteParams>;
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const { params, searchParams } = props;
|
||||
const context = await getSpacePDFContext(await params);
|
||||
return <PDFPage context={context} searchParams={await searchParams} />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getSiteStructureSections } from '@/lib/sites';
|
||||
import type {
|
||||
ChangeRequest,
|
||||
PublishedSiteContentLookup,
|
||||
PublishedSiteContent,
|
||||
RevisionPage,
|
||||
RevisionPageDocument,
|
||||
Site,
|
||||
@@ -13,12 +13,41 @@ import type {
|
||||
SiteStructure,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { type GitBookDataFetcher, createDataFetcher, throwIfDataError } from '@v2/lib/data';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
type GitBookDataFetcher,
|
||||
createDataFetcher,
|
||||
getDataOrNull,
|
||||
throwIfDataError,
|
||||
} from '@v2/lib/data';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { assert } from 'ts-essentials';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from './env';
|
||||
import { GITBOOK_URL } from './env';
|
||||
import { type ImageResizer, createImageResizer } from './images';
|
||||
import { type GitBookSpaceLinker, 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.
|
||||
@@ -32,7 +61,7 @@ export type GitBookBaseContext = {
|
||||
/**
|
||||
* Linker to generate links in the current space.
|
||||
*/
|
||||
linker: GitBookSpaceLinker;
|
||||
linker: GitBookLinker;
|
||||
|
||||
/**
|
||||
* Image resizer to resize images.
|
||||
@@ -102,44 +131,45 @@ export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the base context for a request.
|
||||
* Get the base context for a request on a site.
|
||||
*/
|
||||
export function getBaseContext(input: {
|
||||
siteURL: URL | string;
|
||||
siteURLData: SiteURLData;
|
||||
urlMode: 'url' | 'url-host';
|
||||
apiToken?: string | null;
|
||||
}) {
|
||||
const url = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
|
||||
const urlMode = input.urlMode;
|
||||
const { urlMode, siteURLData } = input;
|
||||
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
|
||||
|
||||
const dataFetcher = createDataFetcher({
|
||||
apiToken: input.apiToken ?? GITBOOK_API_TOKEN,
|
||||
apiEndpoint: GITBOOK_API_URL,
|
||||
apiToken: siteURLData.apiToken ?? null,
|
||||
});
|
||||
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
|
||||
|
||||
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
|
||||
const linker =
|
||||
urlMode === 'url-host'
|
||||
? createLinker({
|
||||
host: url.host,
|
||||
pathname: url.pathname,
|
||||
host: siteURL.host,
|
||||
siteBasePath: siteURLData.siteBasePath,
|
||||
spaceBasePath: siteURLData.basePath,
|
||||
})
|
||||
: createLinker({
|
||||
protocol: gitbookURL?.protocol,
|
||||
host: gitbookURL?.host,
|
||||
pathname: `/url/${url.host}${url.pathname}`,
|
||||
siteBasePath: `/url/${siteURL.host}${siteURLData.siteBasePath}`,
|
||||
spaceBasePath: `/url/${siteURL.host}${siteURLData.basePath}`,
|
||||
});
|
||||
|
||||
if (urlMode === 'url') {
|
||||
// Create link in the same format for links to other sites/sections.
|
||||
linker.toLinkForContent = (rawURL: string) => {
|
||||
const urlObject = new URL(rawURL);
|
||||
return `/url/${urlObject.host}${urlObject.pathname}`;
|
||||
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
|
||||
};
|
||||
}
|
||||
|
||||
const imageResizer = createImageResizer({
|
||||
host: url.host,
|
||||
imagesContextId: siteURLData.imagesContextId,
|
||||
// To ensure image resizing work for proxied sites,
|
||||
// we serve images from the root of the site.
|
||||
linker: linker,
|
||||
@@ -152,59 +182,23 @@ export function getBaseContext(input: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site for a given URL and a base context.
|
||||
*/
|
||||
export async function fetchSiteContextByURL(
|
||||
baseContext: GitBookBaseContext,
|
||||
input: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
const data = await throwIfDataError(
|
||||
dataFetcher.getPublishedContentByUrl({
|
||||
url: input.url,
|
||||
visitorAuthToken: input.visitorAuthToken,
|
||||
redirectOnError: input.redirectOnError,
|
||||
})
|
||||
);
|
||||
|
||||
return fetchSiteContextByURLLookup(baseContext, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site using the resolution of a URL
|
||||
*/
|
||||
export async function fetchSiteContextByURLLookup(
|
||||
baseContext: GitBookBaseContext,
|
||||
data: PublishedSiteContentLookup
|
||||
data: SiteURLData
|
||||
): Promise<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
if ('redirect' in data) {
|
||||
redirect(data.redirect);
|
||||
}
|
||||
|
||||
return await fetchSiteContextByIds(
|
||||
{
|
||||
...baseContext,
|
||||
dataFetcher: dataFetcher.withToken({
|
||||
apiToken: data.apiToken,
|
||||
}),
|
||||
},
|
||||
{
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
space: data.space,
|
||||
shareKey: data.shareKey,
|
||||
changeRequest: data.changeRequest,
|
||||
revision: data.revision,
|
||||
}
|
||||
);
|
||||
return await fetchSiteContextByIds(baseContext, {
|
||||
organization: data.organization,
|
||||
site: data.site,
|
||||
siteSection: data.siteSection,
|
||||
siteSpace: data.siteSpace,
|
||||
space: data.space,
|
||||
shareKey: data.shareKey,
|
||||
changeRequest: data.changeRequest,
|
||||
revision: data.revision,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,7 +307,7 @@ export async function fetchSpaceContextByIds(
|
||||
})
|
||||
),
|
||||
ids.changeRequest
|
||||
? throwIfDataError(
|
||||
? getDataOrNull(
|
||||
dataFetcher.getChangeRequest({
|
||||
spaceId: ids.space,
|
||||
changeRequestId: ids.changeRequest,
|
||||
@@ -322,17 +316,30 @@ export async function fetchSpaceContextByIds(
|
||||
: null,
|
||||
]);
|
||||
|
||||
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
|
||||
if (ids.changeRequest && !changeRequest) {
|
||||
// When trying to render a change request with an invalid / non-existing ID,
|
||||
// we should return a 404.
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pages = await throwIfDataError(
|
||||
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
|
||||
|
||||
const pages = await getDataOrNull(
|
||||
dataFetcher.getRevisionPages({
|
||||
spaceId: ids.space,
|
||||
revisionId,
|
||||
// We only care about the Git metadata when the Git sync is enabled,
|
||||
// otherwise we can optimize performance by not fetching it
|
||||
metadata: !!space.gitSync,
|
||||
})
|
||||
}),
|
||||
|
||||
// When trying to render a revision with an invalid / non-existing ID,
|
||||
// we should handle gracefully the 404 and throw notFound.
|
||||
ids.revision ? [404] : undefined
|
||||
);
|
||||
if (!pages) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
...baseContext,
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
import { type ComputedContentSource, GitBookAPI } from '@gitbook/api';
|
||||
import { trace } from '@/lib/tracing';
|
||||
import {
|
||||
type ComputedContentSource,
|
||||
GitBookAPI,
|
||||
type GitBookAPIServiceBinding,
|
||||
type RenderIntegrationUI,
|
||||
} from '@gitbook/api';
|
||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { wrapDataFetcherError } from './errors';
|
||||
import { DataFetcherError, wrapDataFetcherError } from './errors';
|
||||
import { memoize } from './memoize';
|
||||
import type { GitBookDataFetcher } from './types';
|
||||
|
||||
interface DataFetcherInput {
|
||||
/**
|
||||
* API host to use.
|
||||
*/
|
||||
apiEndpoint: string;
|
||||
|
||||
/**
|
||||
* API token.
|
||||
*/
|
||||
apiToken: string | null;
|
||||
}
|
||||
|
||||
const commonInput: DataFetcherInput = {
|
||||
apiEndpoint: GITBOOK_API_URL,
|
||||
apiToken: GITBOOK_API_TOKEN,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a data fetcher using an API token.
|
||||
* The data are being cached by Next.js built-in cache.
|
||||
*/
|
||||
export function createDataFetcher(input: DataFetcherInput = commonInput): GitBookDataFetcher {
|
||||
export function createDataFetcher(
|
||||
input: DataFetcherInput = { apiToken: null }
|
||||
): GitBookDataFetcher {
|
||||
return {
|
||||
apiEndpoint: input.apiEndpoint,
|
||||
|
||||
async api() {
|
||||
return getAPI(input);
|
||||
return apiClient(input);
|
||||
},
|
||||
|
||||
withToken({ apiToken }) {
|
||||
return createDataFetcher({
|
||||
...input,
|
||||
apiToken,
|
||||
});
|
||||
},
|
||||
@@ -45,125 +41,165 @@ export function createDataFetcher(input: DataFetcherInput = commonInput): GitBoo
|
||||
// API that are tied to the token
|
||||
//
|
||||
getPublishedContentSite(params) {
|
||||
return getPublishedContentSite(input, {
|
||||
organizationId: params.organizationId,
|
||||
siteId: params.siteId,
|
||||
siteShareKey: params.siteShareKey,
|
||||
});
|
||||
return trace('getPublishedContentSite', () =>
|
||||
getPublishedContentSite(input, {
|
||||
organizationId: params.organizationId,
|
||||
siteId: params.siteId,
|
||||
siteShareKey: params.siteShareKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
getSiteRedirectBySource(params) {
|
||||
return getSiteRedirectBySource(input, {
|
||||
organizationId: params.organizationId,
|
||||
siteId: params.siteId,
|
||||
siteShareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
});
|
||||
return trace('getSiteRedirectBySource', () =>
|
||||
getSiteRedirectBySource(input, {
|
||||
organizationId: params.organizationId,
|
||||
siteId: params.siteId,
|
||||
siteShareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevision(params) {
|
||||
return getRevision(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
return trace('getRevision', () =>
|
||||
getRevision(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata,
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionPages(params) {
|
||||
return getRevisionPages(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
return trace('getRevisionPages', () =>
|
||||
getRevisionPages(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
metadata: params.metadata,
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionFile(params) {
|
||||
return getRevisionFile(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
fileId: params.fileId,
|
||||
});
|
||||
return trace('getRevisionFile', () =>
|
||||
getRevisionFile(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
fileId: params.fileId,
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionPageByPath(params) {
|
||||
return getRevisionPageByPath(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
path: params.path,
|
||||
});
|
||||
return trace('getRevisionPageByPath', () =>
|
||||
getRevisionPageByPath(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
path: params.path,
|
||||
})
|
||||
);
|
||||
},
|
||||
getRevisionPageMarkdown(params) {
|
||||
return trace('getRevisionPageMarkdown', () =>
|
||||
getRevisionPageMarkdown(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
pageId: params.pageId,
|
||||
})
|
||||
);
|
||||
},
|
||||
getReusableContent(params) {
|
||||
return getReusableContent(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
reusableContentId: params.reusableContentId,
|
||||
});
|
||||
return trace('getReusableContent', () =>
|
||||
getReusableContent(input, {
|
||||
spaceId: params.spaceId,
|
||||
revisionId: params.revisionId,
|
||||
reusableContentId: params.reusableContentId,
|
||||
})
|
||||
);
|
||||
},
|
||||
getLatestOpenAPISpecVersionContent(params) {
|
||||
return getLatestOpenAPISpecVersionContent(input, {
|
||||
organizationId: params.organizationId,
|
||||
slug: params.slug,
|
||||
});
|
||||
return trace('getLatestOpenAPISpecVersionContent', () =>
|
||||
getLatestOpenAPISpecVersionContent(input, {
|
||||
organizationId: params.organizationId,
|
||||
slug: params.slug,
|
||||
})
|
||||
);
|
||||
},
|
||||
getSpace(params) {
|
||||
return getSpace(input, {
|
||||
spaceId: params.spaceId,
|
||||
shareKey: params.shareKey,
|
||||
});
|
||||
return trace('getSpace', () =>
|
||||
getSpace(input, {
|
||||
spaceId: params.spaceId,
|
||||
shareKey: params.shareKey,
|
||||
})
|
||||
);
|
||||
},
|
||||
getChangeRequest(params) {
|
||||
return getChangeRequest(input, {
|
||||
spaceId: params.spaceId,
|
||||
changeRequestId: params.changeRequestId,
|
||||
});
|
||||
return trace('getChangeRequest', () =>
|
||||
getChangeRequest(input, {
|
||||
spaceId: params.spaceId,
|
||||
changeRequestId: params.changeRequestId,
|
||||
})
|
||||
);
|
||||
},
|
||||
getDocument(params) {
|
||||
return getDocument(input, {
|
||||
spaceId: params.spaceId,
|
||||
documentId: params.documentId,
|
||||
});
|
||||
return trace('getDocument', () =>
|
||||
getDocument(input, {
|
||||
spaceId: params.spaceId,
|
||||
documentId: params.documentId,
|
||||
})
|
||||
);
|
||||
},
|
||||
getComputedDocument(params) {
|
||||
return getComputedDocument(input, {
|
||||
organizationId: params.organizationId,
|
||||
spaceId: params.spaceId,
|
||||
source: params.source,
|
||||
});
|
||||
return trace('getComputedDocument', () =>
|
||||
getComputedDocument(input, {
|
||||
organizationId: params.organizationId,
|
||||
spaceId: params.spaceId,
|
||||
source: params.source,
|
||||
seed: params.seed,
|
||||
})
|
||||
);
|
||||
},
|
||||
getEmbedByUrl(params) {
|
||||
return getEmbedByUrl(input, {
|
||||
url: params.url,
|
||||
spaceId: params.spaceId,
|
||||
});
|
||||
return trace('getEmbedByUrl', () =>
|
||||
getEmbedByUrl(input, {
|
||||
url: params.url,
|
||||
spaceId: params.spaceId,
|
||||
})
|
||||
);
|
||||
},
|
||||
searchSiteContent(params) {
|
||||
return searchSiteContent(input, params);
|
||||
return trace('searchSiteContent', () => searchSiteContent(input, params));
|
||||
},
|
||||
|
||||
//
|
||||
// API that are not tied to the token
|
||||
// where the data is the same for all users
|
||||
//
|
||||
getUserById(userId) {
|
||||
return getUserById(commonInput, userId);
|
||||
renderIntegrationUi(params) {
|
||||
return trace('renderIntegrationUi', () =>
|
||||
renderIntegrationUi(input, {
|
||||
integrationName: params.integrationName,
|
||||
request: params.request,
|
||||
})
|
||||
);
|
||||
},
|
||||
getPublishedContentByUrl(params) {
|
||||
return getPublishedContentByUrl(commonInput, {
|
||||
url: params.url,
|
||||
visitorAuthToken: params.visitorAuthToken,
|
||||
redirectOnError: params.redirectOnError,
|
||||
});
|
||||
|
||||
getUserById(userId) {
|
||||
return trace('getUserById', () => getUserById(input, { userId }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserById(input: DataFetcherInput, userId: string) {
|
||||
const getUserById = memoize(async function getUserById(
|
||||
input: DataFetcherInput,
|
||||
params: { userId: string }
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('days');
|
||||
return trace('getUserById.uncached', () => {
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).users.getUserById(userId);
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.users.getUserById(params.userId);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getSpace(
|
||||
const getSpace = memoize(async function getSpace(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -172,23 +208,26 @@ async function getSpace(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'space',
|
||||
space: params.spaceId,
|
||||
})
|
||||
);
|
||||
return trace('getSpace.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'space',
|
||||
space: params.spaceId,
|
||||
})
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
|
||||
shareKey: params.shareKey,
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getSpaceById(params.spaceId, {
|
||||
shareKey: params.shareKey,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getChangeRequest(
|
||||
const getChangeRequest = memoize(async function getChangeRequest(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -197,45 +236,28 @@ async function getChangeRequest(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('minutes');
|
||||
return trace('getChangeRequest.uncached', () => {
|
||||
cacheLife('minutes');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getChangeRequestById(
|
||||
params.spaceId,
|
||||
params.changeRequestId
|
||||
);
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'change-request',
|
||||
space: params.spaceId,
|
||||
changeRequest: res.data.id,
|
||||
})
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getRevision(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
metadata: boolean;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||
metadata: params.metadata,
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getChangeRequestById(
|
||||
params.spaceId,
|
||||
params.changeRequestId
|
||||
);
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'change-request',
|
||||
space: params.spaceId,
|
||||
changeRequest: res.data.id,
|
||||
})
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getRevisionPages(
|
||||
const getRevision = memoize(async function getRevision(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -245,21 +267,47 @@ async function getRevisionPages(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('max');
|
||||
return trace('getRevision.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.listPagesInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
{
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getRevisionById(params.spaceId, params.revisionId, {
|
||||
metadata: params.metadata,
|
||||
}
|
||||
);
|
||||
return res.data.pages;
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getRevisionFile(
|
||||
const getRevisionPages = memoize(async function getRevisionPages(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
metadata: boolean;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionPages.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.listPagesInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
{
|
||||
metadata: params.metadata,
|
||||
}
|
||||
);
|
||||
return res.data.pages;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getRevisionFile = memoize(async function getRevisionFile(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -269,19 +317,56 @@ async function getRevisionFile(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('max');
|
||||
return trace('getRevisionFile.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getFileInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.fileId
|
||||
);
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getFileInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.fileId,
|
||||
{}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getRevisionPageByPath(
|
||||
const getRevisionPageMarkdown = memoize(async function getRevisionPageMarkdown(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
pageId: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getRevisionPageMarkdown.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getPageInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.pageId,
|
||||
{
|
||||
format: 'markdown',
|
||||
}
|
||||
);
|
||||
|
||||
if (!('markdown' in res.data)) {
|
||||
throw new DataFetcherError('Page is not a document', 404);
|
||||
}
|
||||
|
||||
return res.data.markdown;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getRevisionPageByPath = memoize(async function getRevisionPageByPath(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -291,21 +376,25 @@ async function getRevisionPageByPath(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('max');
|
||||
return trace('getRevisionPageByPath.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
const encodedPath = encodeURIComponent(params.path);
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getPageInRevisionByPath(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
encodedPath
|
||||
);
|
||||
const encodedPath = encodeURIComponent(params.path);
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getPageInRevisionByPath(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
encodedPath,
|
||||
{}
|
||||
);
|
||||
|
||||
return res.data;
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getDocument(
|
||||
const getDocument = memoize(async function getDocument(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -314,45 +403,53 @@ async function getDocument(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('max');
|
||||
return trace('getDocument.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getDocumentById(params.spaceId, params.documentId);
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getDocumentById(params.spaceId, params.documentId, {});
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getComputedDocument(
|
||||
const getComputedDocument = memoize(async function getComputedDocument(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
organizationId: string;
|
||||
source: ComputedContentSource;
|
||||
seed: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('days');
|
||||
return trace('getComputedDocument.uncached', () => {
|
||||
cacheLife('days');
|
||||
|
||||
cacheTag(
|
||||
...getComputedContentSourceCacheTags(
|
||||
{
|
||||
spaceId: params.spaceId,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
params.source
|
||||
)
|
||||
);
|
||||
cacheTag(
|
||||
...getComputedContentSourceCacheTags(
|
||||
{
|
||||
spaceId: params.spaceId,
|
||||
organizationId: params.organizationId,
|
||||
},
|
||||
params.source
|
||||
)
|
||||
);
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
|
||||
source: params.source,
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getComputedDocument(params.spaceId, {
|
||||
source: params.source,
|
||||
seed: params.seed,
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getReusableContent(
|
||||
const getReusableContent = memoize(async function getReusableContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
spaceId: string;
|
||||
@@ -362,87 +459,54 @@ async function getReusableContent(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('max');
|
||||
return trace('getReusableContent.uncached', () => {
|
||||
cacheLife('max');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).spaces.getReusableContentInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.reusableContentId
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getLatestOpenAPISpecVersionContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
slug: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'openapi',
|
||||
organization: params.organizationId,
|
||||
openAPISpec: params.slug,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.getLatestOpenApiSpecVersionContent(
|
||||
params.organizationId,
|
||||
params.slug
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getPublishedContentByUrl(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
const { url, visitorAuthToken, redirectOnError } = params;
|
||||
|
||||
const hostname = new URL(url).hostname;
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'url',
|
||||
hostname,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).urls.getPublishedContentByUrl({
|
||||
url,
|
||||
visitorAuthToken: visitorAuthToken ?? undefined,
|
||||
redirectOnError,
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getReusableContentInRevisionById(
|
||||
params.spaceId,
|
||||
params.revisionId,
|
||||
params.reusableContentId
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if ('site' in res.data) {
|
||||
const getLatestOpenAPISpecVersionContent = memoize(
|
||||
async function getLatestOpenAPISpecVersionContent(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
slug: string;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('getLatestOpenAPISpecVersionContent.uncached', () => {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: res.data.site,
|
||||
tag: 'openapi',
|
||||
organization: params.organizationId,
|
||||
openAPISpec: params.slug,
|
||||
})
|
||||
);
|
||||
}
|
||||
cacheLife('days');
|
||||
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getLatestOpenApiSpecVersionContent(
|
||||
params.organizationId,
|
||||
params.slug
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
async function getPublishedContentSite(
|
||||
const getPublishedContentSite = memoize(async function getPublishedContentSite(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
@@ -452,27 +516,32 @@ async function getPublishedContentSite(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.getPublishedContentSite(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
shareKey: params.siteShareKey,
|
||||
}
|
||||
return trace('getPublishedContentSite.uncached', () => {
|
||||
cacheLife('days');
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getSiteRedirectBySource(
|
||||
return trace('getPublishedContentSite', () => {
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getPublishedContentSite(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
shareKey: params.siteShareKey,
|
||||
}
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getSiteRedirectBySource = memoize(async function getSiteRedirectBySource(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
organizationId: string;
|
||||
@@ -483,29 +552,32 @@ async function getSiteRedirectBySource(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.getSiteRedirectBySource(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
shareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
}
|
||||
return trace('getSiteRedirectBySource.uncached', () => {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: params.siteId,
|
||||
})
|
||||
);
|
||||
cacheLife('days');
|
||||
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.getSiteRedirectBySource(
|
||||
params.organizationId,
|
||||
params.siteId,
|
||||
{
|
||||
shareKey: params.siteShareKey,
|
||||
source: params.source,
|
||||
}
|
||||
);
|
||||
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getEmbedByUrl(
|
||||
const getEmbedByUrl = memoize(async function getEmbedByUrl(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
url: string;
|
||||
@@ -514,40 +586,100 @@ async function getEmbedByUrl(
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
cacheLife('weeks');
|
||||
return trace('getEmbedByUrl.uncached', () => {
|
||||
cacheLife('weeks');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = getAPI(input);
|
||||
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
||||
return res.data;
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.spaces.getEmbedByUrlInSpace(params.spaceId, { url: params.url });
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function searchSiteContent(
|
||||
const searchSiteContent = memoize(async function searchSiteContent(
|
||||
input: DataFetcherInput,
|
||||
params: Parameters<GitBookDataFetcher['searchSiteContent']>[0]
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
const { organizationId, siteId, query, scope } = params;
|
||||
return trace('searchSiteContent.uncached', () => {
|
||||
const { organizationId, siteId, query, scope } = params;
|
||||
|
||||
cacheLife('days');
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const res = await getAPI(input).orgs.searchSiteContent(organizationId, siteId, {
|
||||
query,
|
||||
...scope,
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.orgs.searchSiteContent(organizationId, siteId, {
|
||||
query,
|
||||
...scope,
|
||||
});
|
||||
return res.data.items;
|
||||
});
|
||||
return res.data.items;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const renderIntegrationUi = memoize(async function renderIntegrationUi(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
integrationName: string;
|
||||
request: RenderIntegrationUI;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
return trace('renderIntegrationUi.uncached', () => {
|
||||
cacheTag(getCacheTag({ tag: 'integration', integration: params.integrationName }));
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.integrations.renderIntegrationUiWithPost(
|
||||
params.integrationName,
|
||||
params.request
|
||||
);
|
||||
return res.data;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let loggedServiceBinding = false;
|
||||
|
||||
/**
|
||||
* Create a new API client.
|
||||
*/
|
||||
export async function apiClient(input: DataFetcherInput = { apiToken: null }) {
|
||||
const { apiToken } = input;
|
||||
let serviceBinding: GitBookAPIServiceBinding | undefined;
|
||||
|
||||
try {
|
||||
// HACK: This is a workaround to avoid webpack trying to bundle this cloudflare only module
|
||||
// @ts-ignore
|
||||
const { env } = await import(
|
||||
/* webpackIgnore: true */ `${'__cloudflare:workers'.replaceAll('_', '')}`
|
||||
);
|
||||
serviceBinding = env.GITBOOK_API;
|
||||
if (!loggedServiceBinding) {
|
||||
loggedServiceBinding = true;
|
||||
if (serviceBinding) {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
||||
console.log(`using service binding for the API (${GITBOOK_API_URL})`);
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
||||
console.warn(`no service binding for the API (${GITBOOK_API_URL})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.VERCEL) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getAPI(input: DataFetcherInput) {
|
||||
const { apiEndpoint, apiToken } = input;
|
||||
const api = new GitBookAPI({
|
||||
authToken: apiToken ?? undefined,
|
||||
endpoint: apiEndpoint,
|
||||
authToken: apiToken || GITBOOK_API_TOKEN || undefined,
|
||||
endpoint: GITBOOK_API_URL,
|
||||
userAgent: GITBOOK_USER_AGENT,
|
||||
serviceBinding,
|
||||
});
|
||||
|
||||
return api;
|
||||
|
||||
@@ -93,7 +93,10 @@ export async function wrapDataFetcherError<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function getExposableError(error: Error): DataFetcherErrorData {
|
||||
/**
|
||||
* Get a data fetcher exposable error from a JS error.
|
||||
*/
|
||||
export function getExposableError(error: Error): DataFetcherErrorData {
|
||||
if (error instanceof GitBookAPIError) {
|
||||
return {
|
||||
code: error.code,
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './types';
|
||||
export * from './pages';
|
||||
export * from './urls';
|
||||
export * from './errors';
|
||||
export * from './lookup';
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
|
||||
/**
|
||||
* Lookup a content by its URL using the GitBook API.
|
||||
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||
*/
|
||||
export async function getPublishedContentByURL(input: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
apiToken: string | null;
|
||||
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const api = await apiClient({ apiToken: input.apiToken });
|
||||
|
||||
const callResult = await trace(
|
||||
{
|
||||
operation: 'getPublishedContentByURL',
|
||||
name: alternative.url,
|
||||
},
|
||||
() =>
|
||||
tryCatch(
|
||||
api.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url: alternative.url,
|
||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||
redirectOnError: input.redirectOnError,
|
||||
|
||||
// As this endpoint is cached by our API, we version the request
|
||||
// to void getting stale data with missing properties.
|
||||
// this could be improved by ensuring our API cache layer is versioned
|
||||
// or invalidated when needed
|
||||
// @ts-expect-error - cacheVersion is not a real query param
|
||||
cacheVersion: 'v2',
|
||||
},
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (callResult.error) {
|
||||
if (alternative.primary) {
|
||||
// We only return an error for the primary alternative (full URL),
|
||||
// as other parts could result in errors due to the URL being incomplete (share links, etc).
|
||||
return { error: callResult.error };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { data },
|
||||
} = callResult;
|
||||
|
||||
if ('redirect' in data) {
|
||||
if (alternative.primary) {
|
||||
// Append the path to the redirect URL
|
||||
// because we might have matched a shorter path and the redirect is relative to it
|
||||
if (alternative.extraPath) {
|
||||
if (data.target === 'content') {
|
||||
const redirect = new URL(data.redirect);
|
||||
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
|
||||
data.redirect = redirect.toString();
|
||||
} else {
|
||||
const redirect = new URL(data.redirect);
|
||||
if (redirect.searchParams.has('location')) {
|
||||
redirect.searchParams.set(
|
||||
'location',
|
||||
joinPath(
|
||||
redirect.searchParams.get('location') ?? '',
|
||||
alternative.extraPath
|
||||
)
|
||||
);
|
||||
data.redirect = redirect.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* We use the following criteria to determine if the lookup result is the right one:
|
||||
* - the primary alternative was resolved (because that's the longest or most inclusive path)
|
||||
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
|
||||
*
|
||||
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
|
||||
*/
|
||||
if (alternative.primary || ('site' in data && data.complete)) {
|
||||
const changeRequest = data.changeRequest ?? lookup.changeRequest;
|
||||
const revision = data.revision ?? lookup.revision;
|
||||
|
||||
const siteResult: PublishedSiteContentLookup = {
|
||||
...data,
|
||||
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
|
||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||
...(changeRequest ? { changeRequest } : {}),
|
||||
...(revision ? { revision } : {}),
|
||||
};
|
||||
return { data: siteResult };
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
error: {
|
||||
code: 404,
|
||||
message: 'No content found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
error: getExposableError(result.error),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, mock } from 'bun:test';
|
||||
import { memoize } from './memoize';
|
||||
|
||||
describe('memoize', () => {
|
||||
it('should memoize the function', async () => {
|
||||
const fn = mock(async () => Math.random());
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized()).toBe(await memoized());
|
||||
});
|
||||
|
||||
it('should memoize the function with different arguments', async () => {
|
||||
const fn = mock(async (a: number, b: number) => a + b);
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should memoize a function complex object', async () => {
|
||||
const fn = mock(async (a: { foo: string; bar: number }) => a.foo + a.bar);
|
||||
const memoized = memoize(fn);
|
||||
expect(await memoized({ foo: 'foo', bar: 1 })).toBe(await memoized({ foo: 'foo', bar: 1 }));
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await memoized({ foo: 'foo', bar: 1 })).not.toBe(
|
||||
await memoized({ foo: 'foo', bar: 2 })
|
||||
);
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should wrap concurrent async calls', async () => {
|
||||
const fn = mock(async () => Math.random());
|
||||
const memoized = memoize(fn);
|
||||
const promise1 = memoized();
|
||||
const promise2 = memoized();
|
||||
expect(await promise1).toBe(await promise2);
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import pMemoize from 'p-memoize';
|
||||
|
||||
/**
|
||||
* We wrap 'use cache' calls in a p-memoize function to avoid
|
||||
* executing the function multiple times when doing concurrent calls.
|
||||
*
|
||||
* Hopefully one day this can be done directly by 'use cache'.
|
||||
*/
|
||||
export function memoize<F extends (...args: any[]) => any>(f: F): F {
|
||||
return pMemoize(f, {
|
||||
cacheKey: (args) => {
|
||||
return JSON.stringify(deepSortValue(args));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deepSortValue(value: unknown): unknown {
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(deepSortValue);
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value)
|
||||
.map(([key, subValue]) => {
|
||||
return [key, deepSortValue(subValue)] as const;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -15,12 +15,13 @@ export async function getPageDocument(
|
||||
dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId })
|
||||
);
|
||||
}
|
||||
if (page.computed) {
|
||||
if ('computed' in page && page.computed) {
|
||||
return getDataOrNull(
|
||||
dataFetcher.getComputedDocument({
|
||||
organizationId: space.organization,
|
||||
spaceId: space.id,
|
||||
source: page.computed,
|
||||
seed: page.computedSeed,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ export type DataFetcherResponse<T> =
|
||||
* It is used between v1 and v2.
|
||||
*/
|
||||
export interface GitBookDataFetcher {
|
||||
/**
|
||||
* Endpoint of the API.
|
||||
*/
|
||||
apiEndpoint: string;
|
||||
|
||||
/**
|
||||
* Get an API client for the current context.
|
||||
*/
|
||||
@@ -42,15 +37,6 @@ export interface GitBookDataFetcher {
|
||||
*/
|
||||
getUserById(userId: string): Promise<DataFetcherResponse<api.User>>;
|
||||
|
||||
/**
|
||||
* Get a published content by its URL.
|
||||
*/
|
||||
getPublishedContentByUrl(params: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}): Promise<DataFetcherResponse<api.PublishedSiteContentLookup>>;
|
||||
|
||||
/**
|
||||
* Get a published content site by its organization ID and site ID.
|
||||
*/
|
||||
@@ -111,6 +97,15 @@ export interface GitBookDataFetcher {
|
||||
path: string;
|
||||
}): Promise<DataFetcherResponse<api.RevisionPageDocument | api.RevisionPageGroup>>;
|
||||
|
||||
/**
|
||||
* Get the markdown content of a page by its path.
|
||||
*/
|
||||
getRevisionPageMarkdown(params: {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
pageId: string;
|
||||
}): Promise<DataFetcherResponse<string>>;
|
||||
|
||||
/**
|
||||
* Get a document by its space ID and document ID.
|
||||
*/
|
||||
@@ -125,6 +120,7 @@ export interface GitBookDataFetcher {
|
||||
organizationId: string;
|
||||
spaceId: string;
|
||||
source: api.ComputedContentSource;
|
||||
seed: string;
|
||||
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
||||
|
||||
/**
|
||||
@@ -175,4 +171,12 @@ export interface GitBookDataFetcher {
|
||||
/** Cache bust to ensure the search results are fresh when the space is updated. */
|
||||
cacheBust?: string;
|
||||
}): Promise<DataFetcherResponse<api.SearchSpaceResult[]>>;
|
||||
|
||||
/**
|
||||
* Render an integration UI.
|
||||
*/
|
||||
renderIntegrationUi(params: {
|
||||
integrationName: string;
|
||||
request: api.RenderIntegrationUI;
|
||||
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
|
||||
}
|
||||
|
||||
@@ -1,121 +1,3 @@
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { GitBookAPI, type GitBookAPIError, type PublishedSiteContentLookup } from '@gitbook/api';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}): Promise<
|
||||
| { data: PublishedSiteContentLookup; error?: undefined }
|
||||
| { data?: undefined; error: Error | GitBookAPIError }
|
||||
> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const api = new GitBookAPI({
|
||||
authToken: GITBOOK_API_TOKEN ?? undefined,
|
||||
endpoint: GITBOOK_API_URL,
|
||||
userAgent: GITBOOK_USER_AGENT,
|
||||
});
|
||||
|
||||
const callResult = await tryCatch(
|
||||
api.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url: alternative.url,
|
||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||
redirectOnError: input.redirectOnError,
|
||||
cache: true,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
headers: {
|
||||
'x-gitbook-force-cache': 'true',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (callResult.error) {
|
||||
if (alternative.primary) {
|
||||
// We only return an error for the primary alternative (full URL),
|
||||
// as other parts could result in errors due to the URL being incomplete (share links, etc).
|
||||
return { error: callResult.error };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
data: { data },
|
||||
} = callResult;
|
||||
|
||||
if ('redirect' in data) {
|
||||
if (alternative.primary) {
|
||||
// Append the path to the redirect URL
|
||||
// because we might have matched a shorter path and the redirect is relative to it
|
||||
if (alternative.extraPath) {
|
||||
if (data.target === 'content') {
|
||||
const redirect = new URL(data.redirect);
|
||||
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
|
||||
data.redirect = redirect.toString();
|
||||
} else {
|
||||
const redirect = new URL(data.redirect);
|
||||
if (redirect.searchParams.has('location')) {
|
||||
redirect.searchParams.set(
|
||||
'location',
|
||||
joinPath(
|
||||
redirect.searchParams.get('location') ?? '',
|
||||
alternative.extraPath
|
||||
)
|
||||
);
|
||||
data.redirect = redirect.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* We use the following criteria to determine if the lookup result is the right one:
|
||||
* - the primary alternative was resolved (because that's the longest or most inclusive path)
|
||||
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
|
||||
*
|
||||
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
|
||||
*/
|
||||
if (alternative.primary || ('site' in data && data.complete)) {
|
||||
const siteResult: PublishedSiteContentLookup = {
|
||||
...data,
|
||||
changeRequest: data.changeRequest ?? lookup.changeRequest,
|
||||
revision: data.revision ?? lookup.revision,
|
||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||
};
|
||||
return { data: siteResult };
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
error: new Error('No content found'),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
|
||||
* The approach is optimized to aim at reusing cached lookup results as much as possible.
|
||||
|
||||
+32
-3
@@ -1,3 +1,11 @@
|
||||
import 'server-only';
|
||||
|
||||
/*
|
||||
* Support both Cloudflare and Vercel, environment variables can be bundled.
|
||||
* To avoid leaking them on the client-side, they should be accessed from this file
|
||||
* and not from the `process.env` object.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Main host on which GitBook is running.
|
||||
*/
|
||||
@@ -14,23 +22,28 @@ export const GITBOOK_URL =
|
||||
export const GITBOOK_ASSETS_URL =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'http://localhost:3000'
|
||||
: process.env.GITBOOK_ASSETS_PREFIX || GITBOOK_URL;
|
||||
: process.env.GITBOOK_ASSETS_PREFIX;
|
||||
|
||||
/**
|
||||
* GitBook app URL.
|
||||
*/
|
||||
export const GITBOOK_APP_URL = process.env.NEXT_PUBLIC_GITBOOK_APP_URL || 'https://app.gitbook.com';
|
||||
export const GITBOOK_APP_URL = process.env.GITBOOK_APP_URL || 'https://app.gitbook.com';
|
||||
|
||||
/**
|
||||
* Default GitBook API URL endpoint.
|
||||
*/
|
||||
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL || 'https://api.gitbook.com';
|
||||
|
||||
/**
|
||||
* Default GitBook API URL endpoint, to be shared with the client.
|
||||
*/
|
||||
export const GITBOOK_API_PUBLIC_URL = process.env.GITBOOK_API_PUBLIC_URL || GITBOOK_API_URL;
|
||||
|
||||
/**
|
||||
* Default GitBook API token.
|
||||
* It can be use to avoid rate-limiting.
|
||||
*/
|
||||
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
|
||||
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN || null;
|
||||
|
||||
/**
|
||||
* User agent to use for API requests.
|
||||
@@ -51,6 +64,11 @@ export const GITBOOK_DISABLE_TRACKING = Boolean(
|
||||
export const GITBOOK_INTEGRATIONS_HOST =
|
||||
process.env.GITBOOK_INTEGRATIONS_HOST || 'integrations.gitbook.com';
|
||||
|
||||
/**
|
||||
* Hostname for fonts.
|
||||
*/
|
||||
export const GITBOOK_FONTS_URL = process.env.GITBOOK_FONTS_URL || 'https://fonts.gitbook.com';
|
||||
|
||||
/**
|
||||
* Endpoint to use for resizing images.
|
||||
* It should be a Cloudflare domain with image resizing enabled.
|
||||
@@ -59,6 +77,17 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
|
||||
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
||||
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
||||
|
||||
/**
|
||||
* Endpoint where icons are served.
|
||||
*/
|
||||
export const GITBOOK_ICONS_URL =
|
||||
process.env.GITBOOK_ICONS_URL || `${GITBOOK_ASSETS_URL || ''}/~gitbook/static/icons`;
|
||||
|
||||
/**
|
||||
* Token passed to the icons endpoint.
|
||||
*/
|
||||
export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
|
||||
|
||||
/**
|
||||
* Secret used to validate requests from the GitBook app.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'server-only';
|
||||
|
||||
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||
import type { GitBookSpaceLinker } from '../links';
|
||||
import type { GitBookLinker } from '../links';
|
||||
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||
import type { ImageResizer } from './types';
|
||||
|
||||
@@ -33,13 +33,13 @@ export interface CloudflareImageOptions {
|
||||
* Create an image resizer for a rendering context.
|
||||
*/
|
||||
export function createImageResizer({
|
||||
host,
|
||||
imagesContextId,
|
||||
linker,
|
||||
}: {
|
||||
/** The linker to use to create URLs. */
|
||||
linker: GitBookSpaceLinker;
|
||||
/** The host name of the current site. */
|
||||
host: string;
|
||||
linker: GitBookLinker;
|
||||
/** The site identifier to use for verifying the image signature. */
|
||||
imagesContextId: string;
|
||||
}): ImageResizer {
|
||||
if (!GITBOOK_IMAGE_RESIZE_URL || !GITBOOK_IMAGE_RESIZE_SIGNING_KEY) {
|
||||
return createNoopImageResizer();
|
||||
@@ -58,11 +58,11 @@ export function createImageResizer({
|
||||
|
||||
return async (options) => {
|
||||
cachedSignature ??= await generateImageSignature({
|
||||
host,
|
||||
imagesContextId,
|
||||
url: urlInput,
|
||||
});
|
||||
|
||||
const url = linker.toAbsoluteURL(linker.toPathInContent('/~gitbook/image'));
|
||||
const url = linker.toAbsoluteURL(linker.toPathInSite('/~gitbook/image'));
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('url', getImageAPIUrl(urlInput));
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Get the site identifier to use for image resizing for an incoming request.
|
||||
* This identifier can be obtained before resolving the request URL.
|
||||
*/
|
||||
export function getImageResizingContextId(url: URL): string {
|
||||
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
|
||||
// For proxy requests, we extract the site ID from the pathname
|
||||
// e.g. https://proxy.gitbook.site/site/siteId/...
|
||||
const pathname = url.pathname.slice(1).split('/');
|
||||
return pathname.slice(0, 2).join('/');
|
||||
}
|
||||
|
||||
return url.host;
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './types';
|
||||
export * from './createImageResizer';
|
||||
export * from './signatures';
|
||||
export * from './utils';
|
||||
export * from './getImageResizingContextId';
|
||||
|
||||
@@ -18,7 +18,7 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
|
||||
|
||||
type SignFnInput = {
|
||||
url: string;
|
||||
host: string;
|
||||
imagesContextId: string;
|
||||
};
|
||||
|
||||
type SignFn = (input: SignFnInput) => MaybePromise<string>;
|
||||
@@ -60,7 +60,7 @@ const generateSignatureV2: SignFn = async (input) => {
|
||||
assert(GITBOOK_IMAGE_RESIZE_SIGNING_KEY, 'GITBOOK_IMAGE_RESIZE_SIGNING_KEY is not set');
|
||||
const all = [
|
||||
input.url,
|
||||
input.host, // The hostname is used to avoid serving images from other sites on the same domain
|
||||
input.imagesContextId, // The hostname is used to avoid serving images from other sites on the same domain
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { appendBasePathToLinker, createLinker } from './links';
|
||||
import { createLinker } from './links';
|
||||
|
||||
const root = createLinker({
|
||||
host: 'docs.company.com',
|
||||
pathname: '/',
|
||||
spaceBasePath: '/',
|
||||
siteBasePath: '/',
|
||||
});
|
||||
|
||||
const variantInSection = createLinker({
|
||||
host: 'docs.company.com',
|
||||
pathname: '/section/variant',
|
||||
spaceBasePath: '/section/variant',
|
||||
siteBasePath: '/',
|
||||
});
|
||||
|
||||
const siteGitBookIO = createLinker({
|
||||
host: 'org.gitbook.io',
|
||||
spaceBasePath: '/sitename/variant/',
|
||||
siteBasePath: '/sitename/',
|
||||
});
|
||||
|
||||
describe('toPathInContent', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toPathInContent('some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInContent('some/path')).toBe('/section/variant/some/path');
|
||||
expect(root.toPathInSpace('some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path');
|
||||
});
|
||||
|
||||
it('should handle leading slash', () => {
|
||||
expect(root.toPathInContent('/some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInContent('/some/path')).toBe('/section/variant/some/path');
|
||||
expect(root.toPathInSpace('/some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPathInSite', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toPathInSite('some/path')).toBe('/some/path');
|
||||
expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRelativePathInSite', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toRelativePathInSite('/some/path')).toBe('some/path');
|
||||
expect(siteGitBookIO.toRelativePathInSite('/sitename/some/path')).toBe('some/path');
|
||||
});
|
||||
|
||||
it('should preserve absolute paths outside of the site', () => {
|
||||
expect(siteGitBookIO.toRelativePathInSite('/outside/some/path')).toBe('/outside/some/path');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,27 +58,17 @@ describe('toAbsoluteURL', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendBasePathToLinker', () => {
|
||||
const prefixedRoot = appendBasePathToLinker(root, '/section/variant');
|
||||
const prefixedVariantInSection = appendBasePathToLinker(variantInSection, '/base');
|
||||
|
||||
describe('toPathInContent', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(prefixedRoot.toPathInContent('some/path')).toBe('/section/variant/some/path');
|
||||
expect(prefixedVariantInSection.toPathInContent('some/path')).toBe(
|
||||
'/section/variant/base/some/path'
|
||||
);
|
||||
});
|
||||
describe('toLinkForContent', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toLinkForContent('https://docs.company.com/some/path')).toBe('/some/path');
|
||||
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/sitename/some/path')).toBe(
|
||||
'/sitename/some/path'
|
||||
);
|
||||
});
|
||||
|
||||
describe('toAbsoluteURL', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(prefixedRoot.toAbsoluteURL('some/path')).toBe(
|
||||
'https://docs.company.com/some/path'
|
||||
);
|
||||
expect(prefixedVariantInSection.toAbsoluteURL('some/path')).toBe(
|
||||
'https://docs.company.com/some/path'
|
||||
);
|
||||
});
|
||||
it('should preserve an absolute URL if the site is not the same', () => {
|
||||
expect(siteGitBookIO.toLinkForContent('https://org.gitbook.io/anothersite/some/path')).toBe(
|
||||
'https://org.gitbook.io/anothersite/some/path'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
import type { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import warnOnce from 'warn-once';
|
||||
|
||||
@@ -7,17 +8,28 @@ import warnOnce from 'warn-once';
|
||||
*
|
||||
* URL levels:
|
||||
*
|
||||
* https://docs.company.com/section/variant/page
|
||||
* https://docs.company.com/basename/section/variant/page
|
||||
*
|
||||
* toPathInContent('some/path') => /section/variant/some/path
|
||||
* toPathForPage({ pages, page }) => /section/variant/some/path
|
||||
* toPathInSpace('some/path') => /basename/section/variant/some/path
|
||||
* toPathInSite('some/path') => /basename/some/path
|
||||
* toPathForPage({ pages, page }) => /basename/section/variant/some/path
|
||||
* toAbsoluteURL('some/path') => https://docs.company.com/some/path
|
||||
*/
|
||||
export interface GitBookSpaceLinker {
|
||||
export interface GitBookLinker {
|
||||
/**
|
||||
* Generate an absolute path for a relative path to the current content.
|
||||
*/
|
||||
toPathInContent(relativePath: string): string;
|
||||
toPathInSpace(relativePath: string): string;
|
||||
|
||||
/**
|
||||
* Generate an absolute path for a relative path to the current site.
|
||||
*/
|
||||
toPathInSite(relativePath: string): string;
|
||||
|
||||
/**
|
||||
* Transform an absolute path in a site, to a relative path from the root of the site.
|
||||
*/
|
||||
toRelativePathInSite(absolutePath: string): string;
|
||||
|
||||
/**
|
||||
* Generate an absolute path for a page in the current content.
|
||||
@@ -48,14 +60,36 @@ export function createLinker(
|
||||
servedOn: {
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
pathname: string;
|
||||
|
||||
/** The base path of the space */
|
||||
spaceBasePath: string;
|
||||
|
||||
/** The base path of the site */
|
||||
siteBasePath: string;
|
||||
}
|
||||
): GitBookSpaceLinker {
|
||||
): GitBookLinker {
|
||||
warnOnce(!servedOn.host, 'No host provided to createLinker. It can lead to issues with links.');
|
||||
|
||||
const linker: GitBookSpaceLinker = {
|
||||
toPathInContent(relativePath: string): string {
|
||||
return joinPaths(servedOn.pathname, relativePath);
|
||||
const siteBasePath = withTrailingSlash(withLeadingSlash(servedOn.siteBasePath));
|
||||
const spaceBasePath = withTrailingSlash(withLeadingSlash(servedOn.spaceBasePath));
|
||||
|
||||
const linker: GitBookLinker = {
|
||||
toPathInSpace(relativePath: string): string {
|
||||
return joinPaths(spaceBasePath, relativePath);
|
||||
},
|
||||
|
||||
toPathInSite(relativePath: string): string {
|
||||
return joinPaths(siteBasePath, relativePath);
|
||||
},
|
||||
|
||||
toRelativePathInSite(absolutePath: string): string {
|
||||
const normalizedPath = withLeadingSlash(absolutePath);
|
||||
|
||||
if (!normalizedPath.startsWith(servedOn.siteBasePath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return normalizedPath.slice(servedOn.siteBasePath.length);
|
||||
},
|
||||
|
||||
toAbsoluteURL(absolutePath: string): string {
|
||||
@@ -67,48 +101,25 @@ export function createLinker(
|
||||
},
|
||||
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return linker.toPathInContent(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||
return linker.toPathInSpace(getPagePath(pages, page)) + (anchor ? `#${anchor}` : '');
|
||||
},
|
||||
|
||||
toLinkForContent(url: string): string {
|
||||
return url;
|
||||
toLinkForContent(rawURL: string): string {
|
||||
const url = new URL(rawURL);
|
||||
|
||||
// If the link points to a content in the same site, we return an absolute path
|
||||
// instead of a full URL; it makes it possible to use router navigation
|
||||
if (url.hostname === servedOn.host && url.pathname.startsWith(servedOn.siteBasePath)) {
|
||||
return url.pathname;
|
||||
}
|
||||
|
||||
return rawURL;
|
||||
},
|
||||
};
|
||||
|
||||
return linker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a prefix to a linker.
|
||||
*/
|
||||
export function appendBasePathToLinker(
|
||||
linker: GitBookSpaceLinker,
|
||||
basePath: string
|
||||
): GitBookSpaceLinker {
|
||||
const linkerWithPrefix: GitBookSpaceLinker = {
|
||||
toPathInContent(relativePath: string): string {
|
||||
return linker.toPathInContent(joinPaths(basePath, relativePath));
|
||||
},
|
||||
|
||||
toAbsoluteURL(absolutePath: string): string {
|
||||
return linker.toAbsoluteURL(absolutePath);
|
||||
},
|
||||
|
||||
toPathForPage({ pages, page, anchor }) {
|
||||
return (
|
||||
linkerWithPrefix.toPathInContent(getPagePath(pages, page)) +
|
||||
(anchor ? `#${anchor}` : '')
|
||||
);
|
||||
},
|
||||
|
||||
toLinkForContent(url: string): string {
|
||||
return linker.toLinkForContent(url);
|
||||
},
|
||||
};
|
||||
|
||||
return linkerWithPrefix;
|
||||
}
|
||||
|
||||
function joinPaths(prefix: string, path: string): string {
|
||||
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||||
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CustomizationThemeMode, type PublishedSiteContent } from '@gitbook/api';
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
import type { SiteURLData } from './context';
|
||||
|
||||
export enum MiddlewareHeaders {
|
||||
/**
|
||||
@@ -33,9 +34,10 @@ export enum MiddlewareHeaders {
|
||||
Customization = 'x-gitbook-customization',
|
||||
|
||||
/**
|
||||
* The visitor token used to access this content
|
||||
* The API token used to fetch the content.
|
||||
* This should only be passed for non-site dynamic routes.
|
||||
*/
|
||||
VisitorAuthToken = 'x-gitbook-visitor-token',
|
||||
APIToken = 'x-gitbook-api-token',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +58,7 @@ export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
|
||||
* Get the site URL data from the middleware headers.
|
||||
* This function should only be called in a server action or a dynamic route.
|
||||
*/
|
||||
export async function getSiteURLDataFromMiddleware(): Promise<PublishedSiteContent> {
|
||||
export async function getSiteURLDataFromMiddleware(): Promise<SiteURLData> {
|
||||
const headersList = await headers();
|
||||
const siteURLData = headersList.get(MiddlewareHeaders.SiteURLData);
|
||||
|
||||
@@ -100,15 +102,15 @@ export async function getThemeFromMiddleware() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visitor auth token from the middleware headers.
|
||||
* Get the API token from the middleware headers.
|
||||
* This function should only be called in a dynamic route.
|
||||
*/
|
||||
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
|
||||
export async function getAPITokenFromMiddleware(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
|
||||
if (!visitorAuthToken) {
|
||||
return null;
|
||||
const apiToken = headersList.get(MiddlewareHeaders.APIToken);
|
||||
if (!apiToken) {
|
||||
throw new Error('API token is not set by the middleware');
|
||||
}
|
||||
|
||||
return visitorAuthToken;
|
||||
return apiToken;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export async function getServerActionBaseContext() {
|
||||
|
||||
return getBaseContext({
|
||||
siteURL,
|
||||
siteURLData,
|
||||
urlMode,
|
||||
apiToken: siteURLData.apiToken,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { CustomizationThemeMode, GitBookAPIError } from '@gitbook/api';
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
|
||||
import { getContentSecurityPolicy } from '@/lib/csp';
|
||||
import { validateSerializedCustomization } from '@/lib/customization';
|
||||
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
|
||||
import { getResponseCookiesForVisitorAuth, getVisitorToken } from '@/lib/visitor-token';
|
||||
import {
|
||||
type ResponseCookies,
|
||||
getPathScopedCookieName,
|
||||
getResponseCookiesForVisitorAuth,
|
||||
getVisitorToken,
|
||||
normalizeVisitorAuthURL,
|
||||
} from '@/lib/visitor-token';
|
||||
import { serveResizedImage } from '@/routes/image';
|
||||
import { getPublishedContentByURL } from '@v2/lib/data';
|
||||
import {
|
||||
DataFetcherError,
|
||||
getPublishedContentByURL,
|
||||
normalizeURL,
|
||||
throwIfDataError,
|
||||
} from '@v2/lib/data';
|
||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
||||
import { getImageResizingContextId } from '@v2/lib/images';
|
||||
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
||||
import type { SiteURLData } from './lib/context';
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
@@ -21,27 +35,21 @@ type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
try {
|
||||
// Route all requests to a site
|
||||
const extracted = getSiteURLFromRequest(request);
|
||||
if (extracted) {
|
||||
/**
|
||||
* Serve image resizing requests (all requests containing `/~gitbook/image`).
|
||||
* All URLs containing `/~gitbook/image` are rewritten to `/~gitbook/image`
|
||||
* and serve from a single route handler.
|
||||
*
|
||||
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
|
||||
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
|
||||
*/
|
||||
if (extracted.url.pathname.endsWith('/~gitbook/image')) {
|
||||
return await serveResizedImage(request, {
|
||||
host: extracted.url.host,
|
||||
});
|
||||
}
|
||||
const requestURL = new URL(request.url);
|
||||
|
||||
return await serveSiteByURL(request, extracted);
|
||||
// Redirect to normalize the URL
|
||||
const normalized = normalizeURL(requestURL);
|
||||
if (normalized.toString() !== requestURL.toString()) {
|
||||
return NextResponse.redirect(normalized.toString());
|
||||
}
|
||||
|
||||
for (const handler of [serveSiteRoutes, serveSpacePDFRoutes]) {
|
||||
const result = await handler(requestURL, request);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the rest with the router default logic
|
||||
return NextResponse.next();
|
||||
} catch (error) {
|
||||
return serveErrorResponse(error as Error);
|
||||
@@ -49,110 +57,252 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve site by URL.
|
||||
* Handle request that are targetting the site routes group.
|
||||
*/
|
||||
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
|
||||
const { url, mode } = urlWithMode;
|
||||
async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
const match = getSiteURLFromRequest(request);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Visitor authentication
|
||||
const { url: siteRequestURL, mode } = match;
|
||||
const imagesContextId = getImageResizingContextId(siteRequestURL);
|
||||
|
||||
/**
|
||||
* Serve image resizing requests (all requests containing `/~gitbook/image`).
|
||||
* All URLs containing `/~gitbook/image` are rewritten to `/~gitbook/image`
|
||||
* and serve from a single route handler.
|
||||
*
|
||||
* In GitBook v1: image resizing was done at the root of the hostname (docs.company.com/~gitbook/image)
|
||||
* In GitBook v2: image resizing is done at the content level (docs.company.com/section/variant/~gitbook/image)
|
||||
*/
|
||||
if (siteRequestURL.pathname.endsWith('/~gitbook/image')) {
|
||||
return await serveResizedImage(request, {
|
||||
imagesContextId: imagesContextId,
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Detect and extract the visitor authentication token from the request
|
||||
//
|
||||
// @ts-ignore - request typing
|
||||
const visitorToken = getVisitorToken(request, url);
|
||||
|
||||
const result = await getPublishedContentByURL({
|
||||
url: url.toString(),
|
||||
visitorAuthToken: visitorToken?.token ?? null,
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
const visitorToken = getVisitorToken({
|
||||
cookies: request.cookies.getAll(),
|
||||
url: siteRequestURL,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
const withAPIToken = async (apiToken: string | null) => {
|
||||
const siteURLData = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
url: siteRequestURL.toString(),
|
||||
visitorAuthToken: visitorToken?.token ?? null,
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
|
||||
const { data } = result;
|
||||
// Use the API token passed in the request, if any
|
||||
// as it could be used for .preview hostnames
|
||||
apiToken,
|
||||
})
|
||||
);
|
||||
const cookies: ResponseCookies = [];
|
||||
|
||||
if ('redirect' in data) {
|
||||
return NextResponse.redirect(data.redirect);
|
||||
}
|
||||
//
|
||||
// Handle redirects
|
||||
//
|
||||
if ('redirect' in siteURLData) {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
|
||||
console.log('redirect', siteURLData.redirect);
|
||||
if (siteURLData.target === 'content') {
|
||||
let contentRedirect = new URL(siteURLData.redirect, request.url);
|
||||
|
||||
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
|
||||
let routeType = visitorToken ? 'dynamic' : 'static';
|
||||
// For content redirects, we redirect using the /url/:url format
|
||||
// during development and testing in 'url' mode.
|
||||
if (mode === 'url') {
|
||||
const urlObject = new URL(siteURLData.redirect);
|
||||
contentRedirect = new URL(
|
||||
`/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`,
|
||||
request.url
|
||||
);
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURL, `${url.origin}${data.basePath}`);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
|
||||
// Keep the same search params as the original request
|
||||
// as it might contain a VA token
|
||||
contentRedirect.search = request.nextUrl.search;
|
||||
|
||||
// Preview of customization/theme
|
||||
const customization = url.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
}
|
||||
const theme = url.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||
}
|
||||
return NextResponse.redirect(contentRedirect);
|
||||
}
|
||||
|
||||
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
|
||||
// This is useful for testing dynamic routes.
|
||||
if (request.cookies.has('gitbook-dynamic-route')) {
|
||||
routeType = 'dynamic';
|
||||
}
|
||||
|
||||
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
|
||||
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
||||
requestHeaders.set('origin', request.nextUrl.origin);
|
||||
|
||||
const siteURL = `${url.host}${data.basePath}`;
|
||||
|
||||
const route = [
|
||||
'sites',
|
||||
routeType,
|
||||
mode,
|
||||
encodeURIComponent(siteURL),
|
||||
encodePathInSiteContent(data.pathname),
|
||||
].join('/');
|
||||
|
||||
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
|
||||
|
||||
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
|
||||
const response = NextResponse.rewrite(rewrittenURL, {
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
// Add Content Security Policy header
|
||||
response.headers.set('content-security-policy', getContentSecurityPolicy());
|
||||
// Basic security headers
|
||||
response.headers.set('strict-transport-security', 'max-age=31536000');
|
||||
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
|
||||
response.headers.set('x-content-type-options', 'nosniff');
|
||||
// Debug header
|
||||
response.headers.set('x-gitbook-route-type', routeType);
|
||||
response.headers.set('x-gitbook-site-url', siteURL);
|
||||
|
||||
if (visitorToken) {
|
||||
const cookies = getResponseCookiesForVisitorAuth(data.basePath, visitorToken);
|
||||
for (const [key, value] of Object.entries(cookies)) {
|
||||
response.cookies.set(key, value.value, value.options);
|
||||
return NextResponse.redirect(siteURLData.redirect);
|
||||
}
|
||||
|
||||
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, 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);
|
||||
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup
|
||||
// The token is stored in a cookie that is set on the redirect response
|
||||
//
|
||||
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
|
||||
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||
if (
|
||||
requestURLWithoutToken !== incomingURL &&
|
||||
requestURLWithoutToken.toString() !== incomingURL.toString()
|
||||
) {
|
||||
return writeResponseCookies(
|
||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
||||
cookies
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Render and serve the content
|
||||
//
|
||||
|
||||
// The route is static, except when using dynamic parameters from query params
|
||||
// (customization override, theme, etc)
|
||||
let routeType: 'dynamic' | 'static' = 'static';
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||
requestHeaders.set(
|
||||
MiddlewareHeaders.SiteURL,
|
||||
`${siteCanonicalURL.origin}${siteURLData.basePath}`
|
||||
);
|
||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
||||
|
||||
// Preview of customization/theme
|
||||
const customization = siteRequestURL.searchParams.get('customization');
|
||||
if (customization && validateSerializedCustomization(customization)) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, customization);
|
||||
}
|
||||
const theme = siteRequestURL.searchParams.get('theme');
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||
}
|
||||
|
||||
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
|
||||
// This is useful for testing dynamic routes.
|
||||
if (request.cookies.has('gitbook-dynamic-route')) {
|
||||
routeType = 'dynamic';
|
||||
}
|
||||
|
||||
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
|
||||
requestHeaders.set('x-forwarded-host', request.nextUrl.host);
|
||||
requestHeaders.set('origin', request.nextUrl.origin);
|
||||
|
||||
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
|
||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||
siteURLData.pathname
|
||||
);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
|
||||
// We pick only stable data from the siteURL data to prevent re-rendering of
|
||||
// the root layout when changing pages..
|
||||
const stableSiteURLData: SiteURLData = {
|
||||
site: siteURLData.site,
|
||||
siteSection: siteURLData.siteSection,
|
||||
siteSpace: siteURLData.siteSpace,
|
||||
siteBasePath: siteURLData.siteBasePath,
|
||||
basePath: siteURLData.basePath,
|
||||
space: siteURLData.space,
|
||||
organization: siteURLData.organization,
|
||||
changeRequest: siteURLData.changeRequest,
|
||||
revision: siteURLData.revision,
|
||||
shareKey: siteURLData.shareKey,
|
||||
apiToken: siteURLData.apiToken,
|
||||
imagesContextId: imagesContextId,
|
||||
};
|
||||
|
||||
const route = [
|
||||
'sites',
|
||||
routeType,
|
||||
mode,
|
||||
encodeURIComponent(siteURLWithoutProtocol),
|
||||
encodeURIComponent(
|
||||
rison.encode(
|
||||
// rison can't encode undefined values
|
||||
Object.fromEntries(
|
||||
Object.entries(stableSiteURLData).filter(
|
||||
([_, v]) => typeof v !== 'undefined'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
pathname,
|
||||
].join('/');
|
||||
|
||||
console.log(`rewriting ${request.nextUrl.toString()} to ${route}`);
|
||||
|
||||
const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString());
|
||||
const response = NextResponse.rewrite(rewrittenURL, {
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
// Add Content Security Policy header
|
||||
response.headers.set('content-security-policy', getContentSecurityPolicy());
|
||||
// Basic security headers
|
||||
response.headers.set('strict-transport-security', 'max-age=31536000');
|
||||
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
|
||||
response.headers.set('x-content-type-options', 'nosniff');
|
||||
// Debug header
|
||||
response.headers.set('x-gitbook-route-type', routeType);
|
||||
response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol);
|
||||
|
||||
return writeResponseCookies(response, cookies);
|
||||
};
|
||||
|
||||
// For https://preview/<siteURL> requests,
|
||||
if (siteRequestURL.hostname === 'preview') {
|
||||
return serveWithQueryAPIToken(
|
||||
// We scope the API token to the site ID.
|
||||
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
|
||||
request,
|
||||
withAPIToken
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
return withAPIToken(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve routes for PDF export for a space: /~space/:spaceId/~gitbook/pdf
|
||||
*/
|
||||
async function serveSpacePDFRoutes(requestURL: URL, request: NextRequest) {
|
||||
const pathnameParts = requestURL.pathname.slice(1).split('/');
|
||||
if (pathnameParts[0] !== '~space' && pathnameParts[0] !== '~site') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return serveWithQueryAPIToken(
|
||||
pathnameParts.slice(0, 2).join('/'),
|
||||
request,
|
||||
async (apiToken) => {
|
||||
// Handle the rest with the router default logic
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
[MiddlewareHeaders.APIToken]: apiToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve an error response.
|
||||
*/
|
||||
function serveErrorResponse(error: Error) {
|
||||
if (error instanceof GitBookAPIError) {
|
||||
return new Response(error.errorMessage, {
|
||||
if (error instanceof DataFetcherError) {
|
||||
return new Response(error.message, {
|
||||
status: error.code,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
});
|
||||
@@ -161,6 +311,45 @@ function serveErrorResponse(error: Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server a response with an API token obtained from the query params.
|
||||
*/
|
||||
async function serveWithQueryAPIToken(
|
||||
scopePath: string,
|
||||
request: NextRequest,
|
||||
serve: (apiToken: string) => Promise<NextResponse>
|
||||
) {
|
||||
// We store the API token in a cookie that is scoped to the specific route
|
||||
// to avoid errors when multiple previews are opened in different tabs.
|
||||
const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath);
|
||||
|
||||
// Extract a potential GitBook API token passed in the request
|
||||
// If found, we redirect to the same URL but with the token in the cookie
|
||||
const queryAPIToken = request.nextUrl.searchParams.get('token');
|
||||
if (queryAPIToken) {
|
||||
request.nextUrl.searchParams.delete('token');
|
||||
return writeResponseCookies(NextResponse.redirect(request.nextUrl.toString()), [
|
||||
{
|
||||
name: cookieName,
|
||||
value: queryAPIToken,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const apiToken = request.cookies.get(cookieName)?.value;
|
||||
if (!apiToken) {
|
||||
throw new DataFetcherError('Missing API token', 400);
|
||||
}
|
||||
|
||||
return serve(apiToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of the GitBook content can be passed in 3 different ways (in order of priority):
|
||||
* - The request has a `X-GitBook-URL` header:
|
||||
@@ -219,22 +408,41 @@ function getSiteURLFromRequest(request: NextRequest): URLWithMode | null {
|
||||
* Encode path in a site content.
|
||||
* Special paths are not encoded and passed to be handled by the route handlers.
|
||||
*/
|
||||
function encodePathInSiteContent(rawPathname: string) {
|
||||
function encodePathInSiteContent(rawPathname: string): {
|
||||
pathname: string;
|
||||
routeType?: 'static' | 'dynamic';
|
||||
} {
|
||||
const pathname = removeLeadingSlash(removeTrailingSlash(rawPathname));
|
||||
|
||||
if (pathname.match(/^~gitbook\/ogimage\/\S+$/)) {
|
||||
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) {
|
||||
case '~gitbook/icon':
|
||||
case '~gitbook/image':
|
||||
return { pathname };
|
||||
case 'llms.txt':
|
||||
case 'sitemap.xml':
|
||||
case 'sitemap-pages.xml':
|
||||
case 'robots.txt':
|
||||
return pathname;
|
||||
// LLMs.txt, sitemap, sitemap-pages and robots.txt are always static
|
||||
// as they only depend on the site structure / pages.
|
||||
return { pathname, routeType: 'static' };
|
||||
case '~gitbook/pdf':
|
||||
// PDF routes are always dynamic as they depend on the search params.
|
||||
return { pathname, routeType: 'dynamic' };
|
||||
default:
|
||||
return encodeURIComponent(pathname || '/');
|
||||
return { pathname: encodeURIComponent(pathname || '/') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,3 +456,14 @@ function appendQueryParams(url: URL, from: URLSearchParams) {
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the cookies to a response.
|
||||
*/
|
||||
function writeResponseCookies<R extends NextResponse>(response: R, cookies: ResponseCookies): R {
|
||||
cookies.forEach((cookie) => {
|
||||
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
main = ".open-next/worker.js"
|
||||
name = "gitbook-open-v2"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
compatibility_date = "2025-03-11"
|
||||
compatibility_flags = ["nodejs_compat", "allow_importable_env"]
|
||||
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
||||
observability = { enabled = true }
|
||||
|
||||
@@ -12,6 +12,10 @@ kv_namespaces = [
|
||||
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 = [
|
||||
@@ -24,6 +28,10 @@ kv_namespaces = [
|
||||
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 = [
|
||||
@@ -36,3 +44,7 @@ kv_namespaces = [
|
||||
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" }
|
||||
]
|
||||
|
||||
@@ -23,8 +23,5 @@
|
||||
## Caching
|
||||
# GITBOOK_OUTPUT_CACHE=true
|
||||
|
||||
### Sentry ###
|
||||
# SENTRY_DSN=xxx
|
||||
|
||||
### Silent logs
|
||||
# SILENT=true
|
||||
@@ -23,9 +23,6 @@ next-env.d.ts
|
||||
# visual tests
|
||||
screenshots/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
|
||||
+491
-345
@@ -1,435 +1,581 @@
|
||||
# gitbook
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- da7b369: Fix missing headers in OpenAPIResponses
|
||||
- 139a805: Fix OpenAPI enum display
|
||||
- Updated dependencies [da7b369]
|
||||
- Updated dependencies [da485f5]
|
||||
- Updated dependencies [139a805]
|
||||
- @gitbook/react-openapi@1.1.9
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
|
||||
- 7d0b422: Handle grouped OpenAPISchemas
|
||||
- Updated dependencies [7d0b422]
|
||||
- Updated dependencies [fb90eb0]
|
||||
- @gitbook/react-openapi@1.1.8
|
||||
- @gitbook/colors@0.3.1
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 77fd393: Track event when visitor is opening a search result.
|
||||
- d70d566: Support site announcement banner
|
||||
- 77fd393: Track event when clicking announcement banner link.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e84a46a: Fix OpenAPI tabs indicator overflow
|
||||
- bc90adb: Fix favicon not being displayed in Google because `robots.txt` was preventing the indexation of the image route
|
||||
- 434af90: Fix image resizing when using the proxy feature in a site.
|
||||
- c756761: Add breadcrumbs to search results
|
||||
- 40e8e69: Disallow crawling by web-robots of search/ask URLs
|
||||
- 77fd393: Fix clicking search results when the site is embedded in an iframe.
|
||||
- 1505ddb: Fix multiple request examples selector not showing
|
||||
- 61db166: Add OpenAPI write-only indicator
|
||||
- 6f71da8: Fix padding in schemas
|
||||
- fa91eb7: Fix PDF generation when user has dark mode configured.
|
||||
- 5b1e01c: Support for x-stability property
|
||||
- 57ca4e0: Fix a crash when a page contains a block of an integration that is no longer installed
|
||||
- d236bf0: Fix flash when loading sites with dark mode as default theme
|
||||
- cd99ed5: Fix spec properties rendering and missing keys
|
||||
- 813b2af: Support for x-enumDescriptions and x-gitbook-enum
|
||||
- e9fa50d: Trim the search query to avoid showing a loading state when typing
|
||||
- Updated dependencies [bd35348]
|
||||
- Updated dependencies [ae78fc5]
|
||||
- Updated dependencies [7bb37c7]
|
||||
- Updated dependencies [373183a]
|
||||
- Updated dependencies [1505ddb]
|
||||
- Updated dependencies [61db166]
|
||||
- Updated dependencies [5b1e01c]
|
||||
- Updated dependencies [cd99ed5]
|
||||
- Updated dependencies [813b2af]
|
||||
- Updated dependencies [a25fded]
|
||||
- @gitbook/react-openapi@1.1.7
|
||||
- @gitbook/openapi-parser@2.1.2
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ed07206: Fix OpenAPI path overflow on mobile
|
||||
- 4dab1c5: Fix alignment of `prominent` search bar on full-width pages
|
||||
- 6eae764: Support body examples
|
||||
- 54ee014: Add initial support for loading custom fonts
|
||||
- d2facb2: Mark properties as optional if not required
|
||||
- bba2e52: Fix site redirects when it includes a section/variant path
|
||||
- 4723f03: Restyle section group dropdown
|
||||
- 24b7808: Fix `prominent` search bar width on `md` screens
|
||||
- 1fe3286: Fix OpenAPI block overflow issue
|
||||
- Updated dependencies [48c18c0]
|
||||
- Updated dependencies [6eae764]
|
||||
- Updated dependencies [7212973]
|
||||
- Updated dependencies [d2facb2]
|
||||
- Updated dependencies [73e2b47]
|
||||
- Updated dependencies [70be2c6]
|
||||
- Updated dependencies [fc00b51]
|
||||
- Updated dependencies [a84b06b]
|
||||
- @gitbook/openapi-parser@2.1.1
|
||||
- @gitbook/react-openapi@1.1.6
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 886e204: Update OpenAPI operation path design
|
||||
- Updated dependencies [886e204]
|
||||
- Updated dependencies [4f0a772]
|
||||
- @gitbook/react-openapi@1.1.5
|
||||
- @gitbook/colors@0.3.0
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- eec3eed: Add styling for prominent search bar option
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 16292de: Display sidebar on no-TOC pages
|
||||
- 9b5f971: Transparent background for OpenAPI path block
|
||||
- 99da8df: Optimize favicons and og:image using the image resizer
|
||||
- b011ea0: Fix rendering of code blocks in Ask AI when being streamed
|
||||
- 9bc3d50: Info hint background and link color fixes
|
||||
- 31d800e: Render OpenAPISchemas block
|
||||
- Updated dependencies [6aaeae2]
|
||||
- Updated dependencies [c60e9ba]
|
||||
- Updated dependencies [9108c56]
|
||||
- Updated dependencies [31d800e]
|
||||
- Updated dependencies [ff3b708]
|
||||
- Updated dependencies [f32bf1f]
|
||||
- Updated dependencies [c9ea239]
|
||||
- @gitbook/react-contentkit@0.7.0
|
||||
- @gitbook/react-openapi@1.1.4
|
||||
- @gitbook/cache-tags@0.2.0
|
||||
|
||||
## 0.7.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- eaf2d68: OpenAPI operation title fallback in sections
|
||||
- Updated dependencies [844059f]
|
||||
- Updated dependencies [88f64b0]
|
||||
- @gitbook/react-openapi@1.1.3
|
||||
- @gitbook/icons@0.2.0
|
||||
- @gitbook/react-contentkit@0.6.2
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f127d28: Rename OpenAPIModels to OpenAPISchemas
|
||||
- Updated dependencies [f127d28]
|
||||
- @gitbook/react-openapi@1.1.2
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f574858]
|
||||
- @gitbook/react-openapi@1.1.1
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bb3ca9c: Implement OpenAPI models blocks
|
||||
- bb3ca9c: Implement OpenAPI models blocks
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5907bd9: Adjust hint block spacing
|
||||
- 9ffc3b6: Fix content overflowing out of its container in tabs
|
||||
- Updated dependencies [0278a14]
|
||||
- Updated dependencies [bb3ca9c]
|
||||
- Updated dependencies [3173d8e]
|
||||
- Updated dependencies [052e07a]
|
||||
- @gitbook/openapi-parser@2.1.0
|
||||
- @gitbook/react-openapi@1.1.0
|
||||
- 5907bd9: Adjust hint block spacing
|
||||
- 9ffc3b6: Fix content overflowing out of its container in tabs
|
||||
- Updated dependencies [0278a14]
|
||||
- Updated dependencies [bb3ca9c]
|
||||
- Updated dependencies [3173d8e]
|
||||
- Updated dependencies [052e07a]
|
||||
- @gitbook/openapi-parser@2.1.0
|
||||
- @gitbook/react-openapi@1.1.0
|
||||
|
||||
## 0.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 05ffd0e: Improving data cache management for computed content
|
||||
- 8beb5d6: Add input elements to ContentKit
|
||||
- Updated dependencies [53f5dbe]
|
||||
- Updated dependencies [05ffd0e]
|
||||
- Updated dependencies [8beb5d6]
|
||||
- @gitbook/openapi-parser@2.0.2
|
||||
- @gitbook/cache-tags@0.1.0
|
||||
- @gitbook/react-contentkit@0.6.1
|
||||
- @gitbook/react-openapi@1.0.5
|
||||
- 05ffd0e: Improving data cache management for computed content
|
||||
- 8beb5d6: Add input elements to ContentKit
|
||||
- Updated dependencies [53f5dbe]
|
||||
- Updated dependencies [05ffd0e]
|
||||
- Updated dependencies [8beb5d6]
|
||||
- @gitbook/openapi-parser@2.0.2
|
||||
- @gitbook/cache-tags@0.1.0
|
||||
- @gitbook/react-contentkit@0.6.1
|
||||
- @gitbook/react-openapi@1.0.5
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9b914d1: Fix getProxyModeBasePath that was computing incorrect base path in some scenarios
|
||||
- 2ae76f9: Change how a site in proxy mode is resolved
|
||||
- 027a859: Add support for links style customization option
|
||||
- 3e11678: fix: lost section groups
|
||||
- 3319375: Support OpenAPI operation block
|
||||
- Updated dependencies [722f02e]
|
||||
- Updated dependencies [0924259]
|
||||
- @gitbook/react-openapi@1.0.4
|
||||
- @gitbook/openapi-parser@2.0.1
|
||||
- 9b914d1: Fix getProxyModeBasePath that was computing incorrect base path in some scenarios
|
||||
- 2ae76f9: Change how a site in proxy mode is resolved
|
||||
- 027a859: Add support for links style customization option
|
||||
- 3e11678: fix: lost section groups
|
||||
- 3319375: Support OpenAPI operation block
|
||||
- Updated dependencies [722f02e]
|
||||
- Updated dependencies [0924259]
|
||||
- @gitbook/react-openapi@1.0.4
|
||||
- @gitbook/openapi-parser@2.0.1
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a820739: Remove unused search api method from gitbook/api/lib
|
||||
- a054554: Implement a trusted mode to speed up OpenAPI spec validation
|
||||
- 66d0fc0: Update design for hint block: use semantic colors (info, warning, danger, success) and add alternative styling for hints with headings
|
||||
- 9f0de74: Add support for new OpenAPI ref
|
||||
- da55fac: Render GitBook blocks in OpenAPI operation description
|
||||
- Updated dependencies [c808bb1]
|
||||
- Updated dependencies [dc2dbc5]
|
||||
- Updated dependencies [f1d1d2f]
|
||||
- Updated dependencies [e24206e]
|
||||
- Updated dependencies [a054554]
|
||||
- Updated dependencies [05e1d8c]
|
||||
- Updated dependencies [b4a12d6]
|
||||
- Updated dependencies [9f0de74]
|
||||
- Updated dependencies [da55fac]
|
||||
- @gitbook/openapi-parser@2.0.0
|
||||
- @gitbook/react-openapi@1.0.3
|
||||
- a820739: Remove unused search api method from gitbook/api/lib
|
||||
- a054554: Implement a trusted mode to speed up OpenAPI spec validation
|
||||
- 66d0fc0: Update design for hint block: use semantic colors (info, warning, danger, success) and add alternative styling for hints with headings
|
||||
- 9f0de74: Add support for new OpenAPI ref
|
||||
- da55fac: Render GitBook blocks in OpenAPI operation description
|
||||
- Updated dependencies [c808bb1]
|
||||
- Updated dependencies [dc2dbc5]
|
||||
- Updated dependencies [f1d1d2f]
|
||||
- Updated dependencies [e24206e]
|
||||
- Updated dependencies [a054554]
|
||||
- Updated dependencies [05e1d8c]
|
||||
- Updated dependencies [b4a12d6]
|
||||
- Updated dependencies [9f0de74]
|
||||
- Updated dependencies [da55fac]
|
||||
- @gitbook/openapi-parser@2.0.0
|
||||
- @gitbook/react-openapi@1.0.3
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 359bb97: Fix opening external links when the GitBook page is embedded in an iframe
|
||||
- 6157583: Improve Markdown parsing
|
||||
- 82cd9f2: Add support for anchor links in OpenAPI blocks
|
||||
- Updated dependencies [445baaa]
|
||||
- Updated dependencies [bb5c6a4]
|
||||
- Updated dependencies [a3f1fea]
|
||||
- Updated dependencies [6157583]
|
||||
- Updated dependencies [7419ee7]
|
||||
- Updated dependencies [82cd9f2]
|
||||
- @gitbook/colors@0.2.0
|
||||
- @gitbook/react-openapi@1.0.2
|
||||
- @gitbook/openapi-parser@1.0.1
|
||||
- 359bb97: Fix opening external links when the GitBook page is embedded in an iframe
|
||||
- 6157583: Improve Markdown parsing
|
||||
- 82cd9f2: Add support for anchor links in OpenAPI blocks
|
||||
- Updated dependencies [445baaa]
|
||||
- Updated dependencies [bb5c6a4]
|
||||
- Updated dependencies [a3f1fea]
|
||||
- Updated dependencies [6157583]
|
||||
- Updated dependencies [7419ee7]
|
||||
- Updated dependencies [82cd9f2]
|
||||
- @gitbook/colors@0.2.0
|
||||
- @gitbook/react-openapi@1.0.2
|
||||
- @gitbook/openapi-parser@1.0.1
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dddb4ec: Fix long tab group description
|
||||
- Updated dependencies [f8d4c76]
|
||||
- Updated dependencies [dddb4ec]
|
||||
- Updated dependencies [f8d4c76]
|
||||
- @gitbook/react-openapi@1.0.1
|
||||
- dddb4ec: Fix long tab group description
|
||||
- Updated dependencies [f8d4c76]
|
||||
- Updated dependencies [dddb4ec]
|
||||
- Updated dependencies [f8d4c76]
|
||||
- @gitbook/react-openapi@1.0.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
|
||||
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
|
||||
- 95f2aa4: Track new events for site insights when ads are being clicked
|
||||
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
|
||||
- 1138d59: Add support for sidebar background styles
|
||||
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
|
||||
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
|
||||
- 7059c2b: Add support for computed content by fetching computed documents for pages.
|
||||
- c71d159: Track events for site insights using the new dedicated API.
|
||||
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
|
||||
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
|
||||
- 7ee9158: Restyle PageAside to use sidebar list styles
|
||||
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
|
||||
- 1417279: Track clicks on links (header, footer, content) for site insights.
|
||||
- 9eca010: Improve the display of recommended questions by streaming them.
|
||||
- 160fca1: new OpenAPI blocks design
|
||||
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
|
||||
- 1823101: Fix internal properties appearing in OpenAPI docs.
|
||||
- 6a073e1: Add antialiasing for text rendering
|
||||
- 8126a83: Improve readability of tables with hover style and vertical dividers
|
||||
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
|
||||
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
|
||||
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
|
||||
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
|
||||
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
|
||||
- 95f2aa4: Track new events for site insights when ads are being clicked
|
||||
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
|
||||
- 1138d59: Add support for sidebar background styles
|
||||
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
|
||||
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
|
||||
- 7059c2b: Add support for computed content by fetching computed documents for pages.
|
||||
- c71d159: Track events for site insights using the new dedicated API.
|
||||
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
|
||||
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
|
||||
- 7ee9158: Restyle PageAside to use sidebar list styles
|
||||
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
|
||||
- 1417279: Track clicks on links (header, footer, content) for site insights.
|
||||
- 9eca010: Improve the display of recommended questions by streaming them.
|
||||
- 160fca1: new OpenAPI blocks design
|
||||
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
|
||||
- 1823101: Fix internal properties appearing in OpenAPI docs.
|
||||
- 6a073e1: Add antialiasing for text rendering
|
||||
- 8126a83: Improve readability of tables with hover style and vertical dividers
|
||||
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
|
||||
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
|
||||
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
|
||||
- c30bc24: Fix empty sitemap
|
||||
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
|
||||
- 5b4e710: Support llms.txt
|
||||
- b6c3870: Add support for keyboard marks
|
||||
- 6059efe: Fix search no results error showing while there are results
|
||||
- c77142a: Log component stack in Sentry
|
||||
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
|
||||
- 32aa1f9: Handle security issue with cookies on Safari
|
||||
- d935fb1: Don't add extra page scroll when footer is not present
|
||||
- 53de5b1: Fix site section URL resolution in Ask AI sources
|
||||
- 24f5249: Fix vertical section overflow color
|
||||
- 1762f85: Reduce gap between subsequent header buttons
|
||||
- c1e27cc: Fix pass Sentry release properly
|
||||
- 5ae1b88: Fix shrinking page icons
|
||||
- 8f046a9: Start using tint in more places, TOC and PageAside
|
||||
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
|
||||
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
|
||||
- c30bc24: Fix empty sitemap
|
||||
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
|
||||
- 5b4e710: Support llms.txt
|
||||
- b6c3870: Add support for keyboard marks
|
||||
- 6059efe: Fix search no results error showing while there are results
|
||||
- c77142a: Log component stack in Sentry
|
||||
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
|
||||
- 32aa1f9: Handle security issue with cookies on Safari
|
||||
- d935fb1: Don't add extra page scroll when footer is not present
|
||||
- 53de5b1: Fix site section URL resolution in Ask AI sources
|
||||
- 24f5249: Fix vertical section overflow color
|
||||
- 1762f85: Reduce gap between subsequent header buttons
|
||||
- c1e27cc: Fix pass Sentry release properly
|
||||
- 5ae1b88: Fix shrinking page icons
|
||||
- 8f046a9: Start using tint in more places, TOC and PageAside
|
||||
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
|
||||
|
||||
To reduce the load on the API and also avoid errors.
|
||||
To reduce the load on the API and also avoid errors.
|
||||
|
||||
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
|
||||
- 8cfa67c: Fix default outline list styling
|
||||
- d66c184: Ignore errors from event flushing
|
||||
- 6088fa5: Simplify search results logic to investigate a bug
|
||||
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
|
||||
- 09c7c30: Try to fix error on og image generation
|
||||
- ae99f87: Improve emoji setup, align with GitBook app
|
||||
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
|
||||
- 3a7210d: Fix zoom image view transition on Safari
|
||||
- 718a8a5: Position the variant picker in the ToC
|
||||
- e5dc05e: Update footer styling and allow for more than 4 footer groups
|
||||
- 8276ba0: Make cookies access safer
|
||||
- 1b8a456: Fix Image blocks zoomable behaviour
|
||||
- 56c52e0: Handle Firefox security error on localStorage
|
||||
- 0510b6f: Add section description to SectionGroupTile
|
||||
- 1fcc807: Fix errors from customization not found
|
||||
- 46edde9: Improve the OpenAPI package API
|
||||
- 8af1abc: Improve contrast of search box placeholder
|
||||
- 92b7668: Improve header offset
|
||||
- d9c8d57: Do not dereference before caching OpenAPI spec.
|
||||
- 94876e3: Fix regression issue with page icons for multi-line titles
|
||||
- 47971dc: Fix OG image generation for non-latin characters
|
||||
- 82dc9c4: Simplify the `useHash` algo used.
|
||||
- 128ad20: Ignore cache invalidation error from local backend
|
||||
- ff05e20: Improvements to inline images in headings
|
||||
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
|
||||
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
|
||||
- 48ab59f: Improve colour contrast of list item decoration
|
||||
- d2bc567: Set Sentry release
|
||||
- 37d13d8: Avoid error on fetch by passing a string URL
|
||||
- d3e573c: Generate sitemap for all sections and spaces
|
||||
- f7b801b: Add feedback form to page rating control
|
||||
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
|
||||
- 46f63cb: Fix code format overriding inline link styles
|
||||
- 5950657: Fix emojis display
|
||||
- 528a053: Fix server actions stability leading to no results found sometimes on search
|
||||
- eac1314: Lazy load iframely script to make page more responsive
|
||||
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
|
||||
- 6f54826: New highlight colors
|
||||
- 5c87ec7: Implement a safer way to interact with localStorage.
|
||||
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
|
||||
- 8cfa67c: Fix default outline list styling
|
||||
- d66c184: Ignore errors from event flushing
|
||||
- 6088fa5: Simplify search results logic to investigate a bug
|
||||
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
|
||||
- 09c7c30: Try to fix error on og image generation
|
||||
- ae99f87: Improve emoji setup, align with GitBook app
|
||||
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
|
||||
- 3a7210d: Fix zoom image view transition on Safari
|
||||
- 718a8a5: Position the variant picker in the ToC
|
||||
- e5dc05e: Update footer styling and allow for more than 4 footer groups
|
||||
- 8276ba0: Make cookies access safer
|
||||
- 1b8a456: Fix Image blocks zoomable behaviour
|
||||
- 56c52e0: Handle Firefox security error on localStorage
|
||||
- 0510b6f: Add section description to SectionGroupTile
|
||||
- 1fcc807: Fix errors from customization not found
|
||||
- 46edde9: Improve the OpenAPI package API
|
||||
- 8af1abc: Improve contrast of search box placeholder
|
||||
- 92b7668: Improve header offset
|
||||
- d9c8d57: Do not dereference before caching OpenAPI spec.
|
||||
- 94876e3: Fix regression issue with page icons for multi-line titles
|
||||
- 47971dc: Fix OG image generation for non-latin characters
|
||||
- 82dc9c4: Simplify the `useHash` algo used.
|
||||
- 128ad20: Ignore cache invalidation error from local backend
|
||||
- ff05e20: Improvements to inline images in headings
|
||||
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
|
||||
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
|
||||
- 48ab59f: Improve colour contrast of list item decoration
|
||||
- d2bc567: Set Sentry release
|
||||
- 37d13d8: Avoid error on fetch by passing a string URL
|
||||
- d3e573c: Generate sitemap for all sections and spaces
|
||||
- f7b801b: Add feedback form to page rating control
|
||||
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
|
||||
- 46f63cb: Fix code format overriding inline link styles
|
||||
- 5950657: Fix emojis display
|
||||
- 528a053: Fix server actions stability leading to no results found sometimes on search
|
||||
- eac1314: Lazy load iframely script to make page more responsive
|
||||
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
|
||||
- 6f54826: New highlight colors
|
||||
- 5c87ec7: Implement a safer way to interact with localStorage.
|
||||
|
||||
If it's disabled on the browser it should not throw error.
|
||||
If it's disabled on the browser it should not throw error.
|
||||
|
||||
- 02d876e: Fix search UI behaviour
|
||||
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
|
||||
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
|
||||
- aaab157: Visual fix for section group in Safari
|
||||
- cbe6139: Fix dynamic tabs infinite loop
|
||||
- 65cc4af: Fix error when accessing a change request not found
|
||||
- 727bde2: Improve and split OpenAPI parser into its own package
|
||||
- 0b6ddca: Fix variant selector contrast for non-default themes
|
||||
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
|
||||
- fde32e2: Force route handler to be dynamic to avoid errors
|
||||
- a025118: Change card layout depending on cover aspect ratio
|
||||
- 300f7bf: Fix search loading state
|
||||
- 29d5979: Disable C/C++ highlight temporarily
|
||||
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
|
||||
- 1c97536: Fix Sentry instrumentation
|
||||
- b0bd871: Even safer localStorage
|
||||
- b950a64: Avoid errors on legacy browsers
|
||||
- 38061bd: Add section groups to section tabs
|
||||
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
|
||||
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
|
||||
- 6691492: Fix viewing PDF from space
|
||||
- e8e64bf: Fix bullet list display on full size blocks
|
||||
- 16194c5: Vertical orientation for sections list on sites without header
|
||||
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
|
||||
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
|
||||
- 1429384: Fix error when accessing some not found pages.
|
||||
- 21cbd9e: Change link color to primary-subtle
|
||||
- 5dab70f: Fix "Parser" language syntax highlighting
|
||||
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
|
||||
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
|
||||
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
|
||||
- 44a20fe: Improve smoothness of scroll listener
|
||||
- 5664e5a: Fix variant dropdown styling in header
|
||||
- 6b50360: Fix view transition error on Safari
|
||||
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
|
||||
- 5112e3e: Fix Sentry instrumentation server-side
|
||||
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
|
||||
- Updated dependencies [d9029c7]
|
||||
- Updated dependencies [6e54a06]
|
||||
- Updated dependencies [162b4b7]
|
||||
- Updated dependencies [e4e2f52]
|
||||
- Updated dependencies [0c03676]
|
||||
- Updated dependencies [3e5e458]
|
||||
- Updated dependencies [46edde9]
|
||||
- Updated dependencies [d9c8d57]
|
||||
- Updated dependencies [ccf2cff]
|
||||
- Updated dependencies [dda0cc6]
|
||||
- Updated dependencies [eb7c22f]
|
||||
- Updated dependencies [ea1468c]
|
||||
- Updated dependencies [648f0e9]
|
||||
- Updated dependencies [160fca1]
|
||||
- Updated dependencies [f92e906]
|
||||
- Updated dependencies [e721f17]
|
||||
- Updated dependencies [727bde2]
|
||||
- Updated dependencies [dff08ae]
|
||||
- Updated dependencies [fc7b16f]
|
||||
- Updated dependencies [fe8acc9]
|
||||
- Updated dependencies [1823101]
|
||||
- Updated dependencies [a652958]
|
||||
- Updated dependencies [2f73db7]
|
||||
- Updated dependencies [160fca1]
|
||||
- Updated dependencies [12c7862]
|
||||
- Updated dependencies [b41d425]
|
||||
- @gitbook/react-openapi@1.0.0
|
||||
- @gitbook/openapi-parser@1.0.0
|
||||
- @gitbook/react-contentkit@0.6.0
|
||||
- 02d876e: Fix search UI behaviour
|
||||
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
|
||||
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
|
||||
- aaab157: Visual fix for section group in Safari
|
||||
- cbe6139: Fix dynamic tabs infinite loop
|
||||
- 65cc4af: Fix error when accessing a change request not found
|
||||
- 727bde2: Improve and split OpenAPI parser into its own package
|
||||
- 0b6ddca: Fix variant selector contrast for non-default themes
|
||||
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
|
||||
- fde32e2: Force route handler to be dynamic to avoid errors
|
||||
- a025118: Change card layout depending on cover aspect ratio
|
||||
- 300f7bf: Fix search loading state
|
||||
- 29d5979: Disable C/C++ highlight temporarily
|
||||
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
|
||||
- 1c97536: Fix Sentry instrumentation
|
||||
- b0bd871: Even safer localStorage
|
||||
- b950a64: Avoid errors on legacy browsers
|
||||
- 38061bd: Add section groups to section tabs
|
||||
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
|
||||
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
|
||||
- 6691492: Fix viewing PDF from space
|
||||
- e8e64bf: Fix bullet list display on full size blocks
|
||||
- 16194c5: Vertical orientation for sections list on sites without header
|
||||
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
|
||||
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
|
||||
- 1429384: Fix error when accessing some not found pages.
|
||||
- 21cbd9e: Change link color to primary-subtle
|
||||
- 5dab70f: Fix "Parser" language syntax highlighting
|
||||
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
|
||||
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
|
||||
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
|
||||
- 44a20fe: Improve smoothness of scroll listener
|
||||
- 5664e5a: Fix variant dropdown styling in header
|
||||
- 6b50360: Fix view transition error on Safari
|
||||
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
|
||||
- 5112e3e: Fix Sentry instrumentation server-side
|
||||
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
|
||||
- Updated dependencies [d9029c7]
|
||||
- Updated dependencies [6e54a06]
|
||||
- Updated dependencies [162b4b7]
|
||||
- Updated dependencies [e4e2f52]
|
||||
- Updated dependencies [0c03676]
|
||||
- Updated dependencies [3e5e458]
|
||||
- Updated dependencies [46edde9]
|
||||
- Updated dependencies [d9c8d57]
|
||||
- Updated dependencies [ccf2cff]
|
||||
- Updated dependencies [dda0cc6]
|
||||
- Updated dependencies [eb7c22f]
|
||||
- Updated dependencies [ea1468c]
|
||||
- Updated dependencies [648f0e9]
|
||||
- Updated dependencies [160fca1]
|
||||
- Updated dependencies [f92e906]
|
||||
- Updated dependencies [e721f17]
|
||||
- Updated dependencies [727bde2]
|
||||
- Updated dependencies [dff08ae]
|
||||
- Updated dependencies [fc7b16f]
|
||||
- Updated dependencies [fe8acc9]
|
||||
- Updated dependencies [1823101]
|
||||
- Updated dependencies [a652958]
|
||||
- Updated dependencies [2f73db7]
|
||||
- Updated dependencies [160fca1]
|
||||
- Updated dependencies [12c7862]
|
||||
- Updated dependencies [b41d425]
|
||||
- @gitbook/react-openapi@1.0.0
|
||||
- @gitbook/openapi-parser@1.0.0
|
||||
- @gitbook/react-contentkit@0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
|
||||
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
|
||||
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
|
||||
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
|
||||
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
|
||||
- a2e5647: Fix the styling of site section tabs on smaller screens.
|
||||
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
|
||||
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
|
||||
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
|
||||
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
|
||||
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
|
||||
- a2e5647: Fix the styling of site section tabs on smaller screens.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 076dc48: Fix expandable block anchore resolution
|
||||
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
|
||||
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
|
||||
- 664debc: Add support for tint color
|
||||
- 4d56f11: Update styling of search+ask modal
|
||||
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
|
||||
- 2f76712: Add breadcrumbs above page title
|
||||
- 07cf835: Add scroll margin to the top when there are sections
|
||||
- 5d72b35: Smoother tab transition for sections
|
||||
- 7c71363: Don't adjust fallback font for mono font.
|
||||
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
|
||||
- 87eea73: Fix margin and image resolution of header logo
|
||||
- aa2ed0f: Restyle hint blocks
|
||||
- ffd3937: Fix security issue with image resizing that could be used for phishing
|
||||
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
|
||||
- c73e07d: Increase token max length to fix code not highlighted
|
||||
- 3b3d6e2: Add icons to sections
|
||||
- 1ed18c0: style: adds missing scalar css variables
|
||||
- Updated dependencies [b7a5106]
|
||||
- Updated dependencies [4771c78]
|
||||
- Updated dependencies [ff50ac2]
|
||||
- Updated dependencies [867481c]
|
||||
- Updated dependencies [7ba67fd]
|
||||
- Updated dependencies [a78c1ec]
|
||||
- @gitbook/cache-do@0.1.1
|
||||
- @gitbook/react-openapi@0.7.1
|
||||
- 076dc48: Fix expandable block anchore resolution
|
||||
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
|
||||
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
|
||||
- 664debc: Add support for tint color
|
||||
- 4d56f11: Update styling of search+ask modal
|
||||
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
|
||||
- 2f76712: Add breadcrumbs above page title
|
||||
- 07cf835: Add scroll margin to the top when there are sections
|
||||
- 5d72b35: Smoother tab transition for sections
|
||||
- 7c71363: Don't adjust fallback font for mono font.
|
||||
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
|
||||
- 87eea73: Fix margin and image resolution of header logo
|
||||
- aa2ed0f: Restyle hint blocks
|
||||
- ffd3937: Fix security issue with image resizing that could be used for phishing
|
||||
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
|
||||
- c73e07d: Increase token max length to fix code not highlighted
|
||||
- 3b3d6e2: Add icons to sections
|
||||
- 1ed18c0: style: adds missing scalar css variables
|
||||
- Updated dependencies [b7a5106]
|
||||
- Updated dependencies [4771c78]
|
||||
- Updated dependencies [ff50ac2]
|
||||
- Updated dependencies [867481c]
|
||||
- Updated dependencies [7ba67fd]
|
||||
- Updated dependencies [a78c1ec]
|
||||
- @gitbook/cache-do@0.1.1
|
||||
- @gitbook/react-openapi@0.7.1
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e09f747: Revalidate change request cached content when pressing refresh button
|
||||
- 2fa0851: Add navigation tabs for sections
|
||||
- a4b63b8: Support resolution of new site URLs with sections
|
||||
- 5c35f36: Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
|
||||
- e9b31a5: Unify section tab styles with page item styles
|
||||
- f12a215: Add support for Norwegian language
|
||||
- f4c9536: Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||
- 1f24fe4: Add support for page icons
|
||||
- cda08a9: Add support for searching results in a sections site
|
||||
- b32e40c: Persist state of tabs and dynamically sync them based on title
|
||||
- 15d2ee3: Show the caption for file blocks
|
||||
- f885e88: Improve the toolbar for change-requests and revisions to show more actions
|
||||
- 07ea45b: Remove deprecated synced block from GitBook Open
|
||||
- c3675fd: Added support for new Reusable Content block.
|
||||
- 1f24fe4: Add support for icons style customization for sites
|
||||
- 4c19014: Prevent search indexation for pages where it's configured as disabled
|
||||
- 3422ad4: Update rendering of community ads to match new API response, and make it possible to preview ads.
|
||||
- 1152445: Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
|
||||
- 2c437f7: Fix linking to a tab itself
|
||||
- e09f747: Revalidate change request cached content when pressing refresh button
|
||||
- 2fa0851: Add navigation tabs for sections
|
||||
- a4b63b8: Support resolution of new site URLs with sections
|
||||
- 5c35f36: Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
|
||||
- e9b31a5: Unify section tab styles with page item styles
|
||||
- f12a215: Add support for Norwegian language
|
||||
- f4c9536: Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||
- 1f24fe4: Add support for page icons
|
||||
- cda08a9: Add support for searching results in a sections site
|
||||
- b32e40c: Persist state of tabs and dynamically sync them based on title
|
||||
- 15d2ee3: Show the caption for file blocks
|
||||
- f885e88: Improve the toolbar for change-requests and revisions to show more actions
|
||||
- 07ea45b: Remove deprecated synced block from GitBook Open
|
||||
- c3675fd: Added support for new Reusable Content block.
|
||||
- 1f24fe4: Add support for icons style customization for sites
|
||||
- 4c19014: Prevent search indexation for pages where it's configured as disabled
|
||||
- 3422ad4: Update rendering of community ads to match new API response, and make it possible to preview ads.
|
||||
- 1152445: Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
|
||||
- 2c437f7: Fix linking to a tab itself
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- aa32198: Avoid multiple <h1> in the page by using a <div> for the title in the header
|
||||
- 51fa3ab: Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||
- a7066cc: Fix scroll position when navigating pages on mobile
|
||||
- c754fc9: Add automatic color contrast in site header, restyle search button
|
||||
- 5fe7adb: RND-3532: drop down menu for hidden links at small screen size
|
||||
- 6295881: Change dark mode shadow for multi-space search toolbar
|
||||
- f89b31c: Upgrade the scalar api client package
|
||||
- 13c7534: Use ellipsis and fix icon color for more links in the header on small screen
|
||||
- f885e88: Improve consistency of change request preview by removing cache-control on response
|
||||
- 16e6171: Improve performances of loading pages with embeds by caching them
|
||||
- 34d36c6: Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
|
||||
- af9e66e: Only display spaces dropdown in compact header when site is multi-variants
|
||||
- e3a3d6a: Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||
- 042b850: Automatically scroll to active item in TOC
|
||||
- d43202f: Optimize bundle size of the server output by reducing bundle size of shiki (skipping themes)
|
||||
- bfbed1a: Ensure "Sponsored via GitBook" can be translated in all languages
|
||||
- fe9e6c1: Update ogimage with new design
|
||||
- 17f71ba: Use url hash to open Expandable and scroll to anchor
|
||||
- 3c07e65: Fix margin for paragraphs in quote blocks
|
||||
- 636b868: Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
- f16560c: Include offset in calculations of whether scrollable element is in view
|
||||
- 689f553: Fix inconsistent click area in table because of scroll indicator
|
||||
- 6ce3cea: Stop using KV cache backend for now, but also improves it for higher performances
|
||||
- e914903: Synchronize response and response example tabs
|
||||
- 0f990c7: Show definition title when visible in cards
|
||||
- e3a3d6a: Fix flickering when displaying an "Ask" answer with code blocks
|
||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||
- 3996110: Optimize images rendered in community ads
|
||||
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||
- cb782a7: Fix "ip" being passed to BSA for community ads
|
||||
- a7af3ca: Improving the look and feel of new section tabs
|
||||
- 0bf985a: Don't show hidden pages in the empty state of a page
|
||||
- d6c28a0: Update header styling of sections, variant selector, and button links
|
||||
- aa32198: Avoid multiple <h1> in the page by using a <div> for the title in the header
|
||||
- 51fa3ab: Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||
- a7066cc: Fix scroll position when navigating pages on mobile
|
||||
- c754fc9: Add automatic color contrast in site header, restyle search button
|
||||
- 5fe7adb: RND-3532: drop down menu for hidden links at small screen size
|
||||
- 6295881: Change dark mode shadow for multi-space search toolbar
|
||||
- f89b31c: Upgrade the scalar api client package
|
||||
- 13c7534: Use ellipsis and fix icon color for more links in the header on small screen
|
||||
- f885e88: Improve consistency of change request preview by removing cache-control on response
|
||||
- 16e6171: Improve performances of loading pages with embeds by caching them
|
||||
- 34d36c6: Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
|
||||
- af9e66e: Only display spaces dropdown in compact header when site is multi-variants
|
||||
- e3a3d6a: Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||
- 042b850: Automatically scroll to active item in TOC
|
||||
- d43202f: Optimize bundle size of the server output by reducing bundle size of shiki (skipping themes)
|
||||
- bfbed1a: Ensure "Sponsored via GitBook" can be translated in all languages
|
||||
- fe9e6c1: Update ogimage with new design
|
||||
- 17f71ba: Use url hash to open Expandable and scroll to anchor
|
||||
- 3c07e65: Fix margin for paragraphs in quote blocks
|
||||
- 636b868: Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
- f16560c: Include offset in calculations of whether scrollable element is in view
|
||||
- 689f553: Fix inconsistent click area in table because of scroll indicator
|
||||
- 6ce3cea: Stop using KV cache backend for now, but also improves it for higher performances
|
||||
- e914903: Synchronize response and response example tabs
|
||||
- 0f990c7: Show definition title when visible in cards
|
||||
- e3a3d6a: Fix flickering when displaying an "Ask" answer with code blocks
|
||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||
- 3996110: Optimize images rendered in community ads
|
||||
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||
- cb782a7: Fix "ip" being passed to BSA for community ads
|
||||
- a7af3ca: Improving the look and feel of new section tabs
|
||||
- 0bf985a: Don't show hidden pages in the empty state of a page
|
||||
- d6c28a0: Update header styling of sections, variant selector, and button links
|
||||
|
||||
- Change position of variant selector depending on context (next to logo or in table of contents)
|
||||
- Update section tab styling and animation
|
||||
- Make header buttons smaller with a new `medium` button size
|
||||
- Change position of variant selector depending on context (next to logo or in table of contents)
|
||||
- Update section tab styling and animation
|
||||
- Make header buttons smaller with a new `medium` button size
|
||||
|
||||
- Updated dependencies [51fa3ab]
|
||||
- Updated dependencies [9b8d519]
|
||||
- Updated dependencies [cf3045a]
|
||||
- Updated dependencies [f89b31c]
|
||||
- Updated dependencies [d0f4860]
|
||||
- Updated dependencies [ef9d012]
|
||||
- Updated dependencies [094e9cd]
|
||||
- Updated dependencies [636b868]
|
||||
- Updated dependencies [56f5fa1]
|
||||
- Updated dependencies [5c35f36]
|
||||
- Updated dependencies [4247361]
|
||||
- Updated dependencies [aa8c49e]
|
||||
- Updated dependencies [e914903]
|
||||
- Updated dependencies [4cbcc5b]
|
||||
- Updated dependencies [0f1565c]
|
||||
- Updated dependencies [237b703]
|
||||
- Updated dependencies [51955da]
|
||||
- Updated dependencies [a679e72]
|
||||
- Updated dependencies [c079c3c]
|
||||
- Updated dependencies [5c35f36]
|
||||
- Updated dependencies [776bc31]
|
||||
- @gitbook/react-openapi@0.7.0
|
||||
- @gitbook/cache-do@0.1.0
|
||||
- @gitbook/icons@0.1.0
|
||||
- @gitbook/react-contentkit@0.5.1
|
||||
- @gitbook/react-math@0.6.0
|
||||
- Updated dependencies [51fa3ab]
|
||||
- Updated dependencies [9b8d519]
|
||||
- Updated dependencies [cf3045a]
|
||||
- Updated dependencies [f89b31c]
|
||||
- Updated dependencies [d0f4860]
|
||||
- Updated dependencies [ef9d012]
|
||||
- Updated dependencies [094e9cd]
|
||||
- Updated dependencies [636b868]
|
||||
- Updated dependencies [56f5fa1]
|
||||
- Updated dependencies [5c35f36]
|
||||
- Updated dependencies [4247361]
|
||||
- Updated dependencies [aa8c49e]
|
||||
- Updated dependencies [e914903]
|
||||
- Updated dependencies [4cbcc5b]
|
||||
- Updated dependencies [0f1565c]
|
||||
- Updated dependencies [237b703]
|
||||
- Updated dependencies [51955da]
|
||||
- Updated dependencies [a679e72]
|
||||
- Updated dependencies [c079c3c]
|
||||
- Updated dependencies [5c35f36]
|
||||
- Updated dependencies [776bc31]
|
||||
- @gitbook/react-openapi@0.7.0
|
||||
- @gitbook/cache-do@0.1.0
|
||||
- @gitbook/icons@0.1.0
|
||||
- @gitbook/react-contentkit@0.5.1
|
||||
- @gitbook/react-math@0.6.0
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 24b785c: Update shiki for code block syntax highlighting, with support for more languages and fixes for diffs. It also patches the deployment on Cloudflare to support edge functions larger than 4MB.
|
||||
- 24b785c: Update shiki for code block syntax highlighting, with support for more languages and fixes for diffs. It also patches the deployment on Cloudflare to support edge functions larger than 4MB.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- acc3f2f: Fix error with the "Try it" of OpenAPI block because of the Scalar proxy failing on Cloudflare with the `cache` option
|
||||
- Updated dependencies [709f1a1]
|
||||
- Updated dependencies [ede2335]
|
||||
- Updated dependencies [0426312]
|
||||
- @gitbook/react-openapi@0.6.0
|
||||
- acc3f2f: Fix error with the "Try it" of OpenAPI block because of the Scalar proxy failing on Cloudflare with the `cache` option
|
||||
- Updated dependencies [709f1a1]
|
||||
- Updated dependencies [ede2335]
|
||||
- Updated dependencies [0426312]
|
||||
- @gitbook/react-openapi@0.6.0
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3445db4]
|
||||
- @gitbook/react-contentkit@0.5.0
|
||||
- @gitbook/react-openapi@0.5.0
|
||||
- @gitbook/react-math@0.5.0
|
||||
- Updated dependencies [3445db4]
|
||||
- @gitbook/react-contentkit@0.5.0
|
||||
- @gitbook/react-openapi@0.5.0
|
||||
- @gitbook/react-math@0.5.0
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [24cd72e]
|
||||
- @gitbook/react-contentkit@0.4.0
|
||||
- @gitbook/react-math@0.4.0
|
||||
- @gitbook/react-openapi@0.4.0
|
||||
- Updated dependencies [24cd72e]
|
||||
- @gitbook/react-contentkit@0.4.0
|
||||
- @gitbook/react-math@0.4.0
|
||||
- @gitbook/react-openapi@0.4.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- de747b7: Refactor the repository to be a proper monorepo and publish JS files on NPM instead of TypeScript files.
|
||||
- de747b7: Refactor the repository to be a proper monorepo and publish JS files on NPM instead of TypeScript files.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [de747b7]
|
||||
- Updated dependencies [de747b7]
|
||||
- @gitbook/react-contentkit@0.3.0
|
||||
- @gitbook/react-openapi@0.3.0
|
||||
- @gitbook/react-math@0.3.0
|
||||
- Updated dependencies [de747b7]
|
||||
- Updated dependencies [de747b7]
|
||||
- @gitbook/react-contentkit@0.3.0
|
||||
- @gitbook/react-openapi@0.3.0
|
||||
- @gitbook/react-math@0.3.0
|
||||
|
||||
@@ -1,234 +1,248 @@
|
||||
import { type TestsCase, runTestCases } from './util';
|
||||
import { type TestsCase, runTestCases, waitForCookiesDialog } from './util';
|
||||
|
||||
/** A list of test cases to run on the customers' docs sites. */
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Snyk',
|
||||
baseUrl: 'https://docs.snyk.io',
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
tests: [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
|
||||
{ name: 'Home', url: '/', run: waitForCookiesDialog },
|
||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nexthink',
|
||||
baseUrl: 'https://docs.nexthink.com',
|
||||
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
|
||||
contentBaseURL: 'https://docs.nexthink.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
screenshot: { waitForTOCScrolling: false },
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||
baseUrl: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'jasons-tutorials.gitbook.io',
|
||||
baseUrl: 'https://jasons-tutorials.gitbook.io',
|
||||
contentBaseURL: 'https://jasons-tutorials.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'faq.deltaemulator.com',
|
||||
baseUrl: 'https://faq.deltaemulator.com',
|
||||
contentBaseURL: 'https://faq.deltaemulator.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.dify.ai',
|
||||
baseUrl: 'https://docs.dify.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.dify.ai',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'seeddao.gitbook.io',
|
||||
baseUrl: 'https://seeddao.gitbook.io',
|
||||
contentBaseURL: 'https://seeddao.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'faq.altstore.io',
|
||||
baseUrl: 'https://faq.altstore.io',
|
||||
contentBaseURL: 'https://faq.altstore.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'support.audacityteam.org',
|
||||
baseUrl: 'https://support.audacityteam.org',
|
||||
contentBaseURL: 'https://support.audacityteam.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.gmgn.ai',
|
||||
baseUrl: 'https://docs.gmgn.ai',
|
||||
contentBaseURL: 'https://docs.gmgn.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.spicychat.ai',
|
||||
baseUrl: 'https://docs.spicychat.ai',
|
||||
contentBaseURL: 'https://docs.spicychat.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.portainer.io',
|
||||
baseUrl: 'https://docs.portainer.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.portainer.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.chirptoken.io',
|
||||
baseUrl: 'https://docs.chirptoken.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.chirptoken.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.dexscreener.com',
|
||||
baseUrl: 'https://docs.dexscreener.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.dexscreener.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.pancakeswap.finance',
|
||||
baseUrl: 'https://docs.pancakeswap.finance',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.pancakeswap.finance',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'book.character.ai',
|
||||
baseUrl: 'https://book.character.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://book.character.ai',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.tradeonnova.io',
|
||||
baseUrl: 'https://docs.tradeonnova.io',
|
||||
contentBaseURL: 'https://docs.tradeonnova.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'azcoiner.gitbook.io',
|
||||
baseUrl: 'https://azcoiner.gitbook.io',
|
||||
contentBaseURL: 'https://azcoiner.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.midas.app',
|
||||
baseUrl: 'https://docs.midas.app',
|
||||
contentBaseURL: 'https://docs.midas.app',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.keeper.io',
|
||||
baseUrl: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.keeper.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'adiblar.gitbook.io',
|
||||
baseUrl: 'https://adiblar.gitbook.io',
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.gradient.network',
|
||||
baseUrl: 'https://docs.gradient.network',
|
||||
contentBaseURL: 'https://docs.gradient.network',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'mygate-network.gitbook.io',
|
||||
baseUrl: 'https://mygate-network.gitbook.io',
|
||||
contentBaseURL: 'https://mygate-network.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'treasurenft.gitbook.io',
|
||||
baseUrl: 'https://treasurenft.gitbook.io',
|
||||
contentBaseURL: 'https://treasurenft.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'browndust2.gitbook.io',
|
||||
baseUrl: 'https://browndust2.gitbook.io',
|
||||
contentBaseURL: 'https://browndust2.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
|
||||
},
|
||||
{
|
||||
name: 'junookyo.gitbook.io',
|
||||
baseUrl: 'https://junookyo.gitbook.io',
|
||||
contentBaseURL: 'https://junookyo.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'meshnet.nordvpn.com',
|
||||
baseUrl: 'https://meshnet.nordvpn.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://meshnet.nordvpn.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'manual.bubble.io',
|
||||
baseUrl: 'https://manual.bubble.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://manual.bubble.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.tickettool.xyz',
|
||||
baseUrl: 'https://docs.tickettool.xyz',
|
||||
contentBaseURL: 'https://docs.tickettool.xyz',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'wiki.redmodding.org',
|
||||
baseUrl: 'https://wiki.redmodding.org',
|
||||
contentBaseURL: 'https://wiki.redmodding.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cherry-ai.com',
|
||||
baseUrl: 'https://docs.cherry-ai.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.snyk.io',
|
||||
baseUrl: 'https://docs.snyk.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.realapp.link',
|
||||
baseUrl: 'https://docs.realapp.link',
|
||||
contentBaseURL: 'https://docs.realapp.link',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.plaza.finance',
|
||||
baseUrl: 'https://docs.plaza.finance',
|
||||
contentBaseURL: 'https://docs.plaza.finance',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.publicai.io',
|
||||
baseUrl: 'https://docs.publicai.io',
|
||||
contentBaseURL: 'https://docs.publicai.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'hyperliquid.gitbook.io',
|
||||
baseUrl: 'https://hyperliquid.gitbook.io',
|
||||
contentBaseURL: 'https://hyperliquid.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.umbraco.com',
|
||||
baseUrl: 'https://docs.umbraco.com',
|
||||
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
|
||||
contentBaseURL: 'https://docs.umbraco.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/welcome',
|
||||
run: waitForCookiesDialog,
|
||||
screenshot: { waitForTOCScrolling: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sosovalue-white-paper.gitbook.io',
|
||||
baseUrl: 'https://sosovalue-white-paper.gitbook.io',
|
||||
contentBaseURL: 'https://sosovalue-white-paper.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.revrobotics.com',
|
||||
baseUrl: 'https://docs.revrobotics.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.revrobotics.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'chartschool.stockcharts.com',
|
||||
baseUrl: 'https://chartschool.stockcharts.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://chartschool.stockcharts.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.soniclabs.com',
|
||||
baseUrl: 'https://docs.soniclabs.com',
|
||||
contentBaseURL: 'https://docs.soniclabs.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.meshchain.ai',
|
||||
baseUrl: 'https://docs.meshchain.ai',
|
||||
contentBaseURL: 'https://docs.meshchain.ai',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.thousandeyes.com',
|
||||
baseUrl: 'https://docs.thousandeyes.com',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.thousandeyes.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
{
|
||||
name: 'docs.raydium.io',
|
||||
baseUrl: 'https://docs.raydium.io',
|
||||
contentBaseURL: 'https://docs.raydium.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.fluentbit.io',
|
||||
baseUrl: 'https://docs.fluentbit.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
contentBaseURL: 'https://docs.fluentbit.io',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
getVisitorAuthCookieValue,
|
||||
} from '@/lib/visitor-token';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type TestsCase,
|
||||
allDeprecatedThemePresets,
|
||||
allLocales,
|
||||
allSearchStyles,
|
||||
allSidebarBackgroundStyles,
|
||||
allThemeModes,
|
||||
allThemes,
|
||||
@@ -26,12 +28,13 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'GitBook Site (Single Variant)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
@@ -84,7 +87,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'GitBook Site (Multi Variants)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Variants dropdown',
|
||||
@@ -135,7 +138,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'GitBook Site (Navigation when switching variant)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Keep navigation path/route when switching variant (Public)',
|
||||
@@ -155,8 +158,8 @@ const testCases: TestsCase[] = [
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/2.0/reference/api-reference/pets?fallback=true'
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets')
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -178,15 +181,17 @@ const testCases: TestsCase[] = [
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets?fallback=true'
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes(
|
||||
'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets'
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Keep navigation path/route when switching variant (VA)',
|
||||
screenshot: false,
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'c26190fc-74b2-4b54-9fc7-df9941104953';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -198,7 +203,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
@@ -213,8 +218,10 @@ const testCases: TestsCase[] = [
|
||||
.click();
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/2.0/reference/api-reference/pets?fallback=true'
|
||||
await page.waitForURL((url) =>
|
||||
url.pathname.includes(
|
||||
'api-multi-versions-va/2.0/reference/api-reference/pets'
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -222,7 +229,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'GitBook Site (Sections and Section Groups)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Site with sections and section groups',
|
||||
@@ -244,16 +251,14 @@ const testCases: TestsCase[] = [
|
||||
const sectionGroupDropdown = await page.getByText('Test Section Group 1');
|
||||
await sectionGroupDropdown.hover();
|
||||
await page.getByText('Section B').click();
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/sections/sections-4'
|
||||
);
|
||||
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GitBook',
|
||||
baseUrl: 'https://docs.gitbook.com',
|
||||
contentBaseURL: 'https://docs.gitbook.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
@@ -297,18 +302,30 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Versioning',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Revision',
|
||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Invalid revision',
|
||||
url: '~/revisions/idnotfound/blocks/headings',
|
||||
run: waitForNotFound,
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Invalid change request',
|
||||
url: '~/changes/idnotfound/blocks/headings',
|
||||
run: waitForNotFound,
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PDF',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'PDF',
|
||||
@@ -316,12 +333,95 @@ const testCases: TestsCase[] = [
|
||||
screenshot: {
|
||||
waitForTOCScrolling: false,
|
||||
},
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Space PDF',
|
||||
tests: [
|
||||
{
|
||||
name: 'Main content',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('limit', '10');
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Change request',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('limit', '10');
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `~space/${data.space}/~/changes/HrtgUd5MlFusCMv1elA7/~gitbook/pdf?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Preview',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
name: 'Main content',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: 'Content tests',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -338,7 +438,7 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/block-images',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
screenshot: { threshold: 0.8 },
|
||||
screenshot: { threshold: 0.9 },
|
||||
},
|
||||
{
|
||||
name: 'Images (with zoom)',
|
||||
@@ -349,7 +449,6 @@ const testCases: TestsCase[] = [
|
||||
await zoomImage.first().click();
|
||||
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
|
||||
},
|
||||
fullPage: true,
|
||||
screenshot: { threshold: 0.8 },
|
||||
},
|
||||
{
|
||||
@@ -384,7 +483,12 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Integration Blocks',
|
||||
url: 'blocks/integrations',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
|
||||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Tables',
|
||||
@@ -484,7 +588,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Page options',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Hidden',
|
||||
@@ -518,7 +622,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Customization',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: allThemeModes.flatMap((themeMode) => [
|
||||
{
|
||||
name: `Without header - Theme mode ${themeMode}`,
|
||||
@@ -600,6 +704,23 @@ const testCases: TestsCase[] = [
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
]),
|
||||
...allSearchStyles.flatMap((searchStyle) => ({
|
||||
name: `Theme ${theme} – Search ${searchStyle} – Mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
theme,
|
||||
search: searchStyle,
|
||||
},
|
||||
header: {
|
||||
links: headerLinks,
|
||||
},
|
||||
themes: {
|
||||
default: themeMode,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
]),
|
||||
// Deprecated header themes
|
||||
...allDeprecatedThemePresets.flatMap((preset) => [
|
||||
@@ -652,7 +773,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Ads',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Without previewed ads',
|
||||
@@ -663,7 +784,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Shared space navigation (first site)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-uno/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-uno/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Navigation to shared space',
|
||||
@@ -684,7 +805,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Shared space navigation (second site)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-dos/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-dos/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Navigation to shared space',
|
||||
@@ -704,7 +825,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Site Redirects',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to SSO page',
|
||||
@@ -716,9 +837,24 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Redirects with sections',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||
tests: [
|
||||
{
|
||||
// This test that a redirect that incudes a section path works
|
||||
name: 'Redirect to Quickstart page',
|
||||
url: 'sections-2/redirect-test',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('Quickstart');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Share links',
|
||||
baseUrl: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Valid link',
|
||||
@@ -738,11 +874,11 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Space',
|
||||
baseUrl: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||||
tests: [
|
||||
{
|
||||
name: 'First',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -754,7 +890,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `first?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'first' })
|
||||
@@ -764,7 +900,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Second',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -776,7 +912,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `second?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'second' })
|
||||
@@ -788,11 +924,11 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Collection',
|
||||
baseUrl: 'https://gitbook.gitbook.io/gbo-va-collection/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-collection/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Root',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -804,12 +940,12 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Primary (Space A)',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -820,13 +956,16 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
}
|
||||
);
|
||||
|
||||
// Test that when accessing the non-canonical URL, we are redirected to the canonical URL
|
||||
// with the jwt token in the query string
|
||||
return `spacea?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Space B',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -838,12 +977,12 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `spaceb?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Space C',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -855,18 +994,18 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `spacec?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Space (custom domain)',
|
||||
baseUrl: 'https://test.gitbook.community/',
|
||||
contentBaseURL: 'https://test.gitbook.community/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Root',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -878,12 +1017,12 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'First',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -895,7 +1034,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `first?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'first' })
|
||||
@@ -905,7 +1044,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Custom page',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -917,12 +1056,12 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `custom-page?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Inner page',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -934,14 +1073,14 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `custom-page/inner-page?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to fallback on invalid token pulled from cookie',
|
||||
@@ -973,7 +1112,7 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Show error message when invalid token is passed to url',
|
||||
screenshot: false,
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
@@ -984,7 +1123,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
await expect(page.locator('pre')).toContainText(
|
||||
'Error while validating the JWT token. Reason: The token signature is invalid.'
|
||||
@@ -995,7 +1134,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Languages',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: allLocales.map((locale) => ({
|
||||
name: locale,
|
||||
url: getCustomizationURL({
|
||||
@@ -1011,7 +1150,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'SEO',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Index by default',
|
||||
@@ -1062,11 +1201,11 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Adaptive Content - VA',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-va/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-va/',
|
||||
tests: [
|
||||
{
|
||||
name: 'isAlphaUser',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -1079,7 +1218,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
@@ -1093,7 +1232,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'isBetaUser',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -1106,7 +1245,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
@@ -1120,7 +1259,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'isAlphaUser & isBetaUser',
|
||||
url: (() => {
|
||||
url: () => {
|
||||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
@@ -1134,7 +1273,7 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
},
|
||||
run: async (page) => {
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
@@ -1150,7 +1289,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Adaptive Content - Public',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/',
|
||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/',
|
||||
tests: [
|
||||
{
|
||||
name: 'No custom cookie',
|
||||
@@ -1293,7 +1432,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'Tables',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Default table',
|
||||
|
||||
+102
-45
@@ -2,12 +2,13 @@ import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationFont,
|
||||
CustomizationDefaultFont,
|
||||
type CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLinksStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationSearchStyle,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationTheme,
|
||||
@@ -15,24 +16,24 @@ import {
|
||||
type CustomizationThemedColor,
|
||||
type SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { type BrowserContext, type Page, expect, test } from '@playwright/test';
|
||||
import { type BrowserContext, type Page, type Response, expect, test } from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import rison from 'rison';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
import { getContentTestURL, getTestURL } from '../tests/utils';
|
||||
|
||||
export interface Test {
|
||||
name: string;
|
||||
/**
|
||||
* URL to visit for testing.
|
||||
*/
|
||||
url: string;
|
||||
url: string | (() => string | Promise<string>);
|
||||
cookies?: Parameters<BrowserContext['addCookies']>[0];
|
||||
/**
|
||||
* Test to run
|
||||
*/
|
||||
run?: (page: Page) => Promise<unknown>;
|
||||
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||
/**
|
||||
* Whether the test should be fullscreened during testing.
|
||||
*/
|
||||
@@ -60,11 +61,12 @@ export interface Test {
|
||||
only?: boolean;
|
||||
}
|
||||
|
||||
export interface TestsCase {
|
||||
export type TestsCase = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
skip?: boolean;
|
||||
tests: Array<Test>;
|
||||
}
|
||||
contentBaseURL?: string;
|
||||
};
|
||||
|
||||
export const allLocales: CustomizationLocale[] = [
|
||||
CustomizationLocale.Fr,
|
||||
@@ -109,6 +111,11 @@ export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] =
|
||||
CustomizationSidebarBackgroundStyle.Filled,
|
||||
];
|
||||
|
||||
export const allSearchStyles: CustomizationSearchStyle[] = [
|
||||
CustomizationSearchStyle.Prominent,
|
||||
CustomizationSearchStyle.Subtle,
|
||||
];
|
||||
|
||||
// Common customization settings
|
||||
|
||||
export const headerLinks: CustomizationHeaderItem[] = [
|
||||
@@ -127,11 +134,13 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
||||
];
|
||||
|
||||
export async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
||||
const reject = dialog.getByRole('button', { name: 'Reject' });
|
||||
await expect(accept).toBeVisible();
|
||||
await expect(reject).toBeVisible();
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
}
|
||||
|
||||
export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,12 +148,21 @@ export async function waitForCookiesDialog(page: Page) {
|
||||
*/
|
||||
export function runTestCases(testCases: TestsCase[]) {
|
||||
for (const testCase of testCases) {
|
||||
if (testCase.skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
test.describe(testCase.name, () => {
|
||||
for (const testEntry of testCase.tests) {
|
||||
const testFn = testEntry.only ? test.only : test;
|
||||
testFn(testEntry.name, async ({ page, baseURL, context }) => {
|
||||
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
||||
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
||||
testFn(testEntry.name, async ({ page, context }) => {
|
||||
const testEntryPathname =
|
||||
typeof testEntry.url === 'function' ? await testEntry.url() : testEntry.url;
|
||||
const url = testCase.contentBaseURL
|
||||
? getContentTestURL(
|
||||
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
||||
)
|
||||
: getTestURL(testEntryPathname);
|
||||
if (testEntry.cookies) {
|
||||
await context.addCookies(
|
||||
testEntry.cookies.map((cookie) => ({
|
||||
@@ -170,23 +188,24 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(url);
|
||||
const response = await page.goto(url);
|
||||
if (testEntry.run) {
|
||||
await testEntry.run(page);
|
||||
await testEntry.run(page, response);
|
||||
}
|
||||
const screenshotOptions = testEntry.screenshot;
|
||||
if (screenshotOptions !== false) {
|
||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
||||
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
threshold: screenshotOptions?.threshold ?? undefined,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
beforeScreenshot: async () => {
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
await waitForIcons(page);
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
@@ -244,7 +263,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
|
||||
successColor: { light: '#00C950', dark: '#00C950' },
|
||||
corners: CustomizationCorners.Rounded,
|
||||
font: CustomizationFont.Inter,
|
||||
font: CustomizationDefaultFont.Inter,
|
||||
background: CustomizationBackground.Plain,
|
||||
icons: CustomizationIconsStyle.Regular,
|
||||
links: CustomizationLinksStyle.Default,
|
||||
@@ -252,6 +271,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
background: CustomizationSidebarBackgroundStyle.Default,
|
||||
list: CustomizationSidebarListStyle.Default,
|
||||
},
|
||||
search: CustomizationSearchStyle.Subtle,
|
||||
},
|
||||
internationalization: {
|
||||
locale: CustomizationLocale.En,
|
||||
@@ -307,29 +327,66 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
* Wait for all icons present on the page to be loaded.
|
||||
*/
|
||||
async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(async () => {
|
||||
function loadImage(src: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (_error) => reject(new Error(`Failed to load image: ${src}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const urls = new Set<string>();
|
||||
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||
await Promise.all(
|
||||
icons.map(async (icon) => {
|
||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
await loadImage(url);
|
||||
})
|
||||
);
|
||||
const results = icons.map((icon) => {
|
||||
if (!(icon instanceof SVGElement)) {
|
||||
throw new Error('Icon is not an SVGElement');
|
||||
}
|
||||
|
||||
// If loaded, good it passes the test.
|
||||
if (icon.dataset.loadingState === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not loaded yet, we need to load it.
|
||||
if (icon.dataset.loadingState === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore icons that are not visible.
|
||||
if (!icon.checkVisibility()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
|
||||
// If URL is invalid we throw an error.
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
|
||||
// If the URL is already loaded, we just mark it as loaded.
|
||||
if (urls.has(url)) {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mark the icon as pending and load the image.
|
||||
icon.dataset.loadingState = 'pending';
|
||||
|
||||
// Mark the URL as seen.
|
||||
urls.add(url);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.decode().then(() => {
|
||||
// Wait two frames to let the time to the icon to repaint.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return results.every((x) => x);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +1,49 @@
|
||||
const { withSentryConfig } = require('@sentry/nextjs');
|
||||
|
||||
module.exports = withSentryConfig(
|
||||
{
|
||||
env: {
|
||||
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
|
||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
},
|
||||
|
||||
webpack(config, { dev, webpack }) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
// Required for `swagger2openapi` to work:
|
||||
fs: false,
|
||||
path: false,
|
||||
http: false,
|
||||
};
|
||||
|
||||
// Tree shake debug code for Sentry
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-with-nextjs
|
||||
if (!dev) {
|
||||
config.plugins.push(
|
||||
new webpack.DefinePlugin({
|
||||
__SENTRY_DEBUG__: false,
|
||||
// We always init Sentry with enableTracing: false for now, so this is useless
|
||||
__SENTRY_TRACING__: false,
|
||||
__RRWEB_EXCLUDE_IFRAME__: true,
|
||||
__RRWEB_EXCLUDE_SHADOW_DOM__: true,
|
||||
__SENTRY_EXCLUDE_REPLAY_WORKER__: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
// Cache all static assets for 1 year
|
||||
{
|
||||
source: '/~gitbook/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
poweredByHeader: false,
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.gitbook.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
module.exports = {
|
||||
env: {
|
||||
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||
},
|
||||
{
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: '/~gitbook/monitoring',
|
||||
disableLogger: true,
|
||||
}
|
||||
);
|
||||
webpack(config) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
// Required for `swagger2openapi` to work:
|
||||
fs: false,
|
||||
path: false,
|
||||
http: false,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
// Cache all static assets for 1 year
|
||||
{
|
||||
source: '/~gitbook/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
poweredByHeader: false,
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.gitbook.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.7.0",
|
||||
"version": "0.9.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "0.96.1",
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
@@ -29,25 +29,22 @@
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "8.35.0",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@upstash/redis": "^1.27.1",
|
||||
"ajv": "^8.12.0",
|
||||
"ai": "^4.2.2",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"framer-motion": "^10.16.14",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"micromark": "^4.0.1",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"next": "14.2.23",
|
||||
"memoizee": "^0.4.17",
|
||||
"next": "14.2.25",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -57,22 +54,30 @@
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^1.27.2",
|
||||
"shiki": "^3.2.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"ai": "^4.1.46",
|
||||
"jwt-decode": "^4.0.0"
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.24.5",
|
||||
"event-iterator": "^2.0.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^3.10.0",
|
||||
"@argos-ci/playwright": "^4.3.0",
|
||||
"@cloudflare/next-on-pages": "1.13.7",
|
||||
"vercel": "^39.3.0",
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
@@ -90,8 +95,10 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"stylelint": "^16.16.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3"
|
||||
"typescript": "^5.5.3",
|
||||
"vercel": "^39.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
release: process.env.SENTRY_RELEASE,
|
||||
|
||||
// Disable tracing as it creates additional requests in an env where subrequests are limited.
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Disable transactions as it creates additional requests in an env where subrequests are limited.
|
||||
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
|
||||
beforeSendTransaction: () => {
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { captureException } from '@sentry/nextjs';
|
||||
import NextError from 'next/error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function GlobalError({ error }: { error: Error }) {
|
||||
useEffect(() => {
|
||||
captureException(error);
|
||||
}, [error]);
|
||||
|
||||
export default function GlobalError() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
|
||||
@@ -38,10 +38,9 @@ export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
}
|
||||
|
||||
async function getSitePageProps(props: PageProps) {
|
||||
const { params: rawParams, searchParams: rawSearchParams } = props;
|
||||
const { params: rawParams } = props;
|
||||
|
||||
const params = await rawParams;
|
||||
const searchParams = await rawSearchParams;
|
||||
|
||||
const pointer = await getSiteContentPointer();
|
||||
const context = await fetchV1ContextForSitePointer(pointer);
|
||||
@@ -49,7 +48,5 @@ async function getSitePageProps(props: PageProps) {
|
||||
return {
|
||||
context,
|
||||
pageParams: params,
|
||||
redirectOnFallback: true,
|
||||
fallback: !!searchParams.fallback,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getSiteURLDataFromMiddleware, getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import { getVisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { fetchV1ContextForSitePointer } from '@/lib/v1';
|
||||
@@ -22,14 +23,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
|
||||
const context = await fetchLayoutData();
|
||||
const queryStringTheme = await getThemeFromMiddleware();
|
||||
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||
const siteData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
return (
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={queryStringTheme}
|
||||
withTracking={await shouldTrackEvents()}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
visitorAuthClaims={getVisitorAuthClaims(siteData)}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { captureException, withScope } from '@sentry/nextjs';
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export default function ErrorPage(props: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { error, reset } = props;
|
||||
const { reset } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
React.useEffect(() => {
|
||||
withScope((scope) => {
|
||||
if ('_componentStack' in error && error._componentStack) {
|
||||
scope.setExtra('componentStack', error._componentStack);
|
||||
}
|
||||
captureException(error);
|
||||
});
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -47,9 +35,8 @@ export default function ErrorPage(props: {
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
>
|
||||
{t(language, 'unexpected_error_retry')}
|
||||
</Button>
|
||||
label={tString(language, 'unexpected_error_retry')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { defaultCustomizationForSpace } from '@/lib/utils';
|
||||
|
||||
import { PDFRootLayout } from '@/components/PDF';
|
||||
import { getV1ContextForPDF } from './pointer';
|
||||
|
||||
/**
|
||||
* Layout to be used for the site root. It fetches the customization data for the
|
||||
* site or space and initializes the CustomizationRootLayout with it.
|
||||
*/
|
||||
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
const context = await getV1ContextForPDF();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout
|
||||
customization={
|
||||
'customization' in context ? context.customization : defaultCustomizationForSpace()
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
|
||||
}
|
||||
|
||||
@@ -1,361 +1,17 @@
|
||||
import {
|
||||
type CustomizationSettings,
|
||||
type Revision,
|
||||
type RevisionPageDocument,
|
||||
type RevisionPageGroup,
|
||||
RevisionPageType,
|
||||
type SiteCustomizationSettings,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
type Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import type { GitBookSpaceContext } from '@v2/lib/context';
|
||||
import { getPageDocument } from '@v2/lib/data';
|
||||
import type { GitBookSpaceLinker } from '@v2/lib/links';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
import { DocumentView } from '@/components/DocumentView';
|
||||
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
|
||||
import type { PolymorphicComponentProp } from '@/components/utils/types';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { getAbsoluteHref, getPagePDFContainerId } from '@/lib/links';
|
||||
import { resolvePageId } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { type PDFSearchParams, getPDFSearchParams } from '@/lib/urls';
|
||||
import { defaultCustomizationForSpace } from '@/lib/utils';
|
||||
|
||||
import { PageControlButtons } from './PageControlButtons';
|
||||
import { PrintButton } from './PrintButton';
|
||||
import './pdf.css';
|
||||
import { PDFPage, generatePDFMetadata } from '@/components/PDF';
|
||||
import { getV1ContextForPDF } from './pointer';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
export async function generateMetadata() {
|
||||
const context = await getV1ContextForPDF();
|
||||
|
||||
return {
|
||||
title: 'site' in context ? context.site.title : context.space.title,
|
||||
robots: 'noindex, nofollow',
|
||||
};
|
||||
return generatePDFMetadata(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a space as a standalone HTML page that can be printed as a PDF.
|
||||
*/
|
||||
export default async function PDFHTMLOutput(props: {
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
|
||||
|
||||
// Build current PDF URL and preserve all search params
|
||||
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
|
||||
currentPDFUrl += `?${searchParams.toString()}`;
|
||||
|
||||
// Fetch the context
|
||||
const baseContext = await getV1ContextForPDF();
|
||||
|
||||
const customization =
|
||||
'customization' in baseContext ? baseContext.customization : defaultCustomizationForSpace();
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
// Compute the pages to render
|
||||
const { pages, total } = selectPages(baseContext.pages, pdfParams);
|
||||
const pageIds = pages.map(
|
||||
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string]
|
||||
);
|
||||
|
||||
// Build a linker that create anchor links for the pages rendered in the PDF page.
|
||||
const linker: GitBookSpaceLinker = {
|
||||
...baseContext.linker,
|
||||
toPathForPage(input) {
|
||||
if (pages.some((p) => p.page.id === input.page.id)) {
|
||||
return `#${getPagePDFContainerId(input.page, input.anchor)}`;
|
||||
}
|
||||
if (input.page.type === RevisionPageType.Group) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
// Use an absolute URL to the page
|
||||
return input.page.urls.app;
|
||||
},
|
||||
};
|
||||
|
||||
const context: GitBookSpaceContext = {
|
||||
...baseContext,
|
||||
linker,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="print-mode">
|
||||
{pdfParams.back !== 'false' ? (
|
||||
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
|
||||
<a
|
||||
title={tString(language, 'pdf_goback')}
|
||||
href={pdfParams.back ?? linker.toAbsoluteURL(linker.toPathInContent(''))}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-sm',
|
||||
'text-tint',
|
||||
'hover:text-primary',
|
||||
'p-4',
|
||||
'rounded-full',
|
||||
'bg-white',
|
||||
'shadow-sm',
|
||||
'hover:shadow-md',
|
||||
'border-slate-300',
|
||||
'border'
|
||||
)}
|
||||
>
|
||||
<Icon icon="arrow-left" className={tcls('size-6')} />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={tcls('fixed', 'right-12', 'top-12', 'print:hidden', 'z-50')}>
|
||||
<PrintButton
|
||||
title={tString(language, 'pdf_print')}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-sm',
|
||||
'text-tint',
|
||||
'hover:text-primary',
|
||||
'p-4',
|
||||
'rounded-full',
|
||||
'bg-white',
|
||||
'shadow-sm',
|
||||
'hover:shadow-md',
|
||||
'border-slate-300',
|
||||
'border'
|
||||
)}
|
||||
>
|
||||
<Icon icon="print" className={tcls('size-6')} />
|
||||
</PrintButton>
|
||||
</div>
|
||||
|
||||
<PageControlButtons
|
||||
pageIds={pageIds}
|
||||
pdfHref={currentPDFUrl}
|
||||
singlePageMode={!!pdfParams.only}
|
||||
total={total}
|
||||
limit={pdfParams.limit ?? DEFAULT_LIMIT}
|
||||
trademark={
|
||||
customization.trademark.enabled ? (
|
||||
<TrademarkLink
|
||||
space={context.space}
|
||||
customization={customization}
|
||||
placement={SiteInsightsTrademarkPlacement.Pdf}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{pdfParams.only ? null : (
|
||||
<PDFSpaceIntro space={context.space} customization={customization} />
|
||||
)}
|
||||
{pages.map(({ page }) =>
|
||||
page.type === 'group' ? (
|
||||
<PDFPageGroup key={page.id} space={context.space} page={page} />
|
||||
) : (
|
||||
<React.Suspense
|
||||
key={page.id}
|
||||
fallback={
|
||||
<PrintPage id={getPagePDFContainerId(page)}>
|
||||
<p>Loading...</p>
|
||||
</PrintPage>
|
||||
}
|
||||
>
|
||||
<PDFPageDocument page={page} context={context} />
|
||||
</React.Suspense>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function PDFSpaceIntro(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { space, customization } = props;
|
||||
|
||||
return (
|
||||
<PrintPage isFirst>
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'py-12')}>
|
||||
<h1 className={tcls('text-6xl', 'font-bold')}>
|
||||
{customization.title ?? space.title}
|
||||
</h1>
|
||||
</div>
|
||||
</PrintPage>
|
||||
);
|
||||
}
|
||||
|
||||
async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
|
||||
const { page } = props;
|
||||
|
||||
return (
|
||||
<PrintPage id={getPagePDFContainerId(page)}>
|
||||
<div
|
||||
className={tcls(
|
||||
'break-before-page',
|
||||
'mt-10',
|
||||
'print:mt-0',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'py-12'
|
||||
)}
|
||||
>
|
||||
<h1 className={tcls('text-5xl', 'font-bold')}>{page.title}</h1>
|
||||
</div>
|
||||
</PrintPage>
|
||||
);
|
||||
}
|
||||
|
||||
async function PDFPageDocument(props: {
|
||||
page: RevisionPageDocument;
|
||||
context: GitBookSpaceContext;
|
||||
}) {
|
||||
const { page, context } = props;
|
||||
const { space } = context;
|
||||
const document = await getPageDocument(context.dataFetcher, space, page);
|
||||
|
||||
return (
|
||||
<PrintPage id={getPagePDFContainerId(page)}>
|
||||
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
|
||||
{page.description ? (
|
||||
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
|
||||
) : null}
|
||||
|
||||
{document ? (
|
||||
<DocumentView
|
||||
document={document}
|
||||
style="mt-6 space-y-6"
|
||||
blockStyle="max-w-full"
|
||||
context={{
|
||||
mode: 'print',
|
||||
contentContext: {
|
||||
...context,
|
||||
page,
|
||||
},
|
||||
getId: (id) => getPagePDFContainerId(page, id),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</PrintPage>
|
||||
);
|
||||
}
|
||||
|
||||
function PrintPage(
|
||||
props: PolymorphicComponentProp<
|
||||
'div',
|
||||
{
|
||||
isFirst?: boolean;
|
||||
}
|
||||
>
|
||||
) {
|
||||
const { children, isFirst, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={tcls(
|
||||
className,
|
||||
'my-11',
|
||||
'print:my-0',
|
||||
'mx-auto',
|
||||
'max-w-4xl',
|
||||
'w-full',
|
||||
'p-12',
|
||||
'print:p-0',
|
||||
'shadow-xl',
|
||||
'print:shadow-none',
|
||||
'rounded-sm',
|
||||
'bg-white',
|
||||
'min-h-[29.7cm]',
|
||||
'print:min-h-0',
|
||||
isFirst ? null : 'break-before-page',
|
||||
'break-anywhere'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: number };
|
||||
|
||||
/**
|
||||
* Compute the ordered flat set of pages to render.
|
||||
*/
|
||||
function selectPages(
|
||||
rootPages: Revision['pages'],
|
||||
params: PDFSearchParams
|
||||
): { pages: FlatPageEntry[]; total: number } {
|
||||
const flattenPage = (
|
||||
page: RevisionPageDocument | RevisionPageGroup,
|
||||
depth: number
|
||||
): FlatPageEntry[] => {
|
||||
return [
|
||||
{ page, depth },
|
||||
...page.pages.flatMap((child) => {
|
||||
if (child.type !== 'document') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (child.hidden) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return flattenPage(child, depth + 1);
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const limitTo = (entries: FlatPageEntry[]) => {
|
||||
return {
|
||||
// Apply a soft-limit, the limit can be controlled by the URL to allow testing
|
||||
pages: entries.slice(0, params.limit ?? DEFAULT_LIMIT),
|
||||
total: entries.length,
|
||||
};
|
||||
};
|
||||
|
||||
if (params.page) {
|
||||
const found = resolvePageId(rootPages, params.page);
|
||||
if (!found) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!params.only) {
|
||||
return limitTo([{ page: found.page, depth: 0 }]);
|
||||
}
|
||||
|
||||
return limitTo(flattenPage(found.page, 0));
|
||||
}
|
||||
|
||||
const allPages = rootPages.flatMap((page) => {
|
||||
if (page.type !== 'document' && page.type !== 'group') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (page.hidden) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return flattenPage(page, 0);
|
||||
});
|
||||
return limitTo(allPages);
|
||||
const context = await getV1ContextForPDF();
|
||||
return <PDFPage context={context} searchParams={await props.searchParams} />;
|
||||
}
|
||||
|
||||
@@ -4,23 +4,6 @@ import type { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
|
||||
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
|
||||
import { fetchV1ContextForSitePointer, fetchV1ContextForSpacePointer } from '@/lib/v1';
|
||||
|
||||
/**
|
||||
* PDF generation can be done at the site level (e.g. docs.foo.com/~gitbook/pdf) or
|
||||
* at the space level (e.g. open.gitbook.com/~space/:spaceId/~gitbook/pdf) which is
|
||||
* for PDF export of in-app private spaces.
|
||||
*
|
||||
* This function returns the pointer depending on the context.
|
||||
*/
|
||||
export async function getSiteOrSpacePointerForPDF(): Promise<
|
||||
SiteContentPointer | SpaceContentPointer
|
||||
> {
|
||||
try {
|
||||
return await getSiteContentPointer();
|
||||
} catch (_error) {
|
||||
return getSpacePointer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context for the PDF pointer.
|
||||
*/
|
||||
@@ -31,3 +14,18 @@ export async function getV1ContextForPDF(): Promise<GitBookSiteContext | GitBook
|
||||
? await fetchV1ContextForSitePointer(pointer)
|
||||
: await fetchV1ContextForSpacePointer(pointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF generation can be done at the site level (e.g. docs.foo.com/~gitbook/pdf) or
|
||||
* at the space level (e.g. open.gitbook.com/~space/:spaceId/~gitbook/pdf) which is
|
||||
* for PDF export of in-app private spaces.
|
||||
*
|
||||
* This function returns the pointer depending on the context.
|
||||
*/
|
||||
async function getSiteOrSpacePointerForPDF(): Promise<SiteContentPointer | SpaceContentPointer> {
|
||||
try {
|
||||
return await getSiteContentPointer();
|
||||
} catch (_error) {
|
||||
return getSpacePointer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-ignore
|
||||
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
||||
|
||||
import { withResponseCacheTags } from './lib/cache/response';
|
||||
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||
|
||||
/**
|
||||
@@ -9,8 +10,8 @@ import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const response = await withMiddlewareHeadersStorage(() =>
|
||||
nextOnPagesHandler.fetch(request, env, ctx)
|
||||
const response = await withResponseCacheTags(() =>
|
||||
withMiddlewareHeadersStorage(() => nextOnPagesHandler.fetch(request, env, ctx))
|
||||
);
|
||||
|
||||
return response;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loading } from '../primitives';
|
||||
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
|
||||
|
||||
/**
|
||||
* Summarise a page's content for use in a link preview
|
||||
*/
|
||||
export function AIPageLinkSummary(props: {
|
||||
currentSpaceId: string;
|
||||
currentPageId: string;
|
||||
currentPageTitle: string;
|
||||
targetSpaceId: string;
|
||||
targetPageId: string;
|
||||
linkPreview?: string;
|
||||
linkTitle?: string;
|
||||
showTrademark: boolean;
|
||||
}) {
|
||||
const {
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
showTrademark = true,
|
||||
} = props;
|
||||
|
||||
const [highlight, setHighlight] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
setHighlight('');
|
||||
|
||||
(async () => {
|
||||
const stream = await streamLinkPageSummary({
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
previousPageIds: [],
|
||||
});
|
||||
|
||||
for await (const highlight of stream) {
|
||||
if (canceled) return;
|
||||
setHighlight(highlight ?? '');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [currentSpaceId, currentPageId, targetSpaceId, targetPageId, linkPreview, linkTitle]);
|
||||
|
||||
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={!highlight || highlight.length === 0} />
|
||||
) : (
|
||||
<Icon icon="sparkle" className="size-3" />
|
||||
)}
|
||||
<h6 className="text-tint">Page highlight</h6>
|
||||
</div>
|
||||
{highlight.length > 0 ? <p>{highlight}</p> : null}
|
||||
{highlight.length > 0 ? (
|
||||
<div className="text-tint-subtle text-xs">
|
||||
Based on your context. May contain mistakes.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AIPageLinkSummary';
|
||||
@@ -0,0 +1,124 @@
|
||||
'use server';
|
||||
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
|
||||
import type { GitBookBaseContext } from '@v2/lib/context';
|
||||
import { EventIterator } from 'event-iterator';
|
||||
import type { MaybePromise } from 'p-map';
|
||||
import * as partialJson from 'partial-json';
|
||||
import type { DeepPartial } from 'ts-essentials';
|
||||
import type { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
/**
|
||||
* Get the latest value from a stream and the response id.
|
||||
*/
|
||||
export async function generate<T>(
|
||||
promise: MaybePromise<{
|
||||
stream: EventIterator<T>;
|
||||
response: Promise<{ responseId: string }>;
|
||||
}>
|
||||
) {
|
||||
const input = await promise;
|
||||
let value: T | undefined;
|
||||
|
||||
for await (const event of input.stream) {
|
||||
value = event;
|
||||
}
|
||||
|
||||
const { responseId } = await input.response;
|
||||
return {
|
||||
responseId,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the generation of an object using the AI.
|
||||
*/
|
||||
export async function streamGenerateObject<T>(
|
||||
context: GitBookBaseContext,
|
||||
{
|
||||
organizationId,
|
||||
siteId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
},
|
||||
{
|
||||
schema,
|
||||
messages,
|
||||
model = AIModel.Fast,
|
||||
}: {
|
||||
schema: z.ZodSchema<T>;
|
||||
messages: AIMessageInput[];
|
||||
model?: AIModel;
|
||||
previousResponseId?: string;
|
||||
}
|
||||
) {
|
||||
const apiClient = await context.dataFetcher.api();
|
||||
|
||||
const rawStream = apiClient.orgs.streamAiResponseInSite(organizationId, siteId, {
|
||||
input: messages,
|
||||
output: {
|
||||
type: 'object',
|
||||
schema: zodToJsonSchema(schema),
|
||||
},
|
||||
model,
|
||||
});
|
||||
|
||||
let json = '';
|
||||
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
|
||||
if (event.type === 'response_object') {
|
||||
json += event.jsonChunk;
|
||||
|
||||
const parsed = partialJson.parse(json, partialJson.ALL);
|
||||
return parsed;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stream from the API to extract the responseId.
|
||||
*/
|
||||
function parseResponse<T>(
|
||||
responseStream: EventIterator<AIStreamResponse>,
|
||||
parse: (response: AIStreamResponse) => T | undefined
|
||||
): {
|
||||
stream: EventIterator<T>;
|
||||
response: Promise<{ responseId: string }>;
|
||||
} {
|
||||
let resolveResponse: (value: { responseId: string }) => void;
|
||||
const response = new Promise<{ responseId: string }>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
|
||||
const stream = new EventIterator<T>((queue) => {
|
||||
(async () => {
|
||||
let foundResponse = false;
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (event.type === 'response_finish') {
|
||||
foundResponse = true;
|
||||
resolveResponse({ responseId: event.responseId });
|
||||
} else {
|
||||
const parsed = parse(event);
|
||||
if (parsed !== undefined) {
|
||||
queue.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundResponse) {
|
||||
throw new Error('No response found');
|
||||
}
|
||||
})().then(
|
||||
() => {
|
||||
queue.stop();
|
||||
},
|
||||
(error) => {
|
||||
queue.fail(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return { stream, response };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './streamLinkPageSummary';
|
||||
@@ -0,0 +1,127 @@
|
||||
'use server';
|
||||
import { getV1BaseContext } from '@/lib/v1';
|
||||
import { isV2 } from '@/lib/v2';
|
||||
import { AIMessageRole } from '@gitbook/api';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||
import { z } from 'zod';
|
||||
import { streamGenerateObject } from './api';
|
||||
|
||||
/**
|
||||
* Get a summary of a page, in the context of another page
|
||||
*/
|
||||
export async function* streamLinkPageSummary({
|
||||
currentSpaceId,
|
||||
currentPageId,
|
||||
targetSpaceId,
|
||||
targetPageId,
|
||||
linkPreview,
|
||||
linkTitle,
|
||||
}: {
|
||||
currentSpaceId: string;
|
||||
currentPageId: string;
|
||||
targetSpaceId: string;
|
||||
targetPageId: string;
|
||||
linkPreview?: string;
|
||||
linkTitle?: string;
|
||||
previousPageIds?: string[];
|
||||
}) {
|
||||
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
const [{ stream }] = await Promise.all([
|
||||
streamGenerateObject(
|
||||
baseContext,
|
||||
{
|
||||
organizationId: siteURLData.organization,
|
||||
siteId: siteURLData.site,
|
||||
},
|
||||
{
|
||||
schema: z.object({
|
||||
highlight: z.string().describe('The most important content of the target page'),
|
||||
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||
}),
|
||||
messages: [
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# Role
|
||||
You are a documentation navigator. Your job is to help the user read documentation more efficiently. Your aim is to prevent the user from having to read the target page by giving them all the information they need to know.
|
||||
|
||||
# Task
|
||||
Using both the current page context and the target page content, produce a page highlight that:
|
||||
- Highlights the key facts from the target page.
|
||||
- Relates strongly to the topic the user is currently reading about.
|
||||
- Is very succinct and direct, using only one or two short sentences (each sentence using no more than one comma).
|
||||
- Remains strictly factual, without referring to “the page”.
|
||||
|
||||
# Instructions
|
||||
1. Identify the key paragraph surrounding the link's text (e.g., “change request”) from the current page.
|
||||
2. Extract and combine relevant information from the target page to address the link's context.
|
||||
3. Combine in one or two short sentences that are direct and brief.
|
||||
|
||||
# Examples
|
||||
## Example 1
|
||||
- Link context: “This feature is only available on the Ultimate plan.”
|
||||
- Link preview: “Pricing: Learn about our different pricing tiers.”
|
||||
- Response: “The Ultimate plan costs $25 per month. A Pro plan is available too.”
|
||||
|
||||
## Example 2
|
||||
- Link context: “You can use keyboard shortcuts to get to the Search menu faster.”
|
||||
- Link preview: “Keyboard shortcuts: A quick reference guide to all the keyboard shortcuts available.”
|
||||
- Response: “To open the Search menu, use the keyboard shortcut ⌘K or Ctrl+K.”
|
||||
|
||||
## Example 3
|
||||
- Link context: “This feature can only be enabled by an admin.”
|
||||
- Link preview: “Roles: An overview of the different roles on the platform.”
|
||||
- Response: “The admin role is reserved for the creator of the organisation.”`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `# Context
|
||||
## Current page
|
||||
The content of the current page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page',
|
||||
spaceId: currentSpaceId,
|
||||
pageId: currentPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Target page
|
||||
The content of the target page is:`,
|
||||
attachments: [
|
||||
{
|
||||
type: 'page',
|
||||
spaceId: targetSpaceId,
|
||||
pageId: targetPageId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.Developer,
|
||||
content: `## Link preview
|
||||
The content of the link preview is:
|
||||
> ${linkPreview}`,
|
||||
},
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: `I'm considering reading the link titled "${linkTitle}" to page ID ${targetPageId}. Give the most relevant information from this page. Relate it to my current page and in particular the paragraph I'm currently reading. Be very concise.`,
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
fetchServerActionSiteContext(baseContext),
|
||||
]);
|
||||
|
||||
for await (const value of stream) {
|
||||
const highlight = value.highlight;
|
||||
if (!highlight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield highlight;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { context } = props;
|
||||
const mode = await headers().get('x-gitbook-mode');
|
||||
const mode = (await headers()).get('x-gitbook-mode');
|
||||
|
||||
if (mode === 'multi-id') {
|
||||
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import type { GitBookSiteContext } from '@v2/lib/context';
|
||||
import { AnnouncementBanner } from './AnnouncementBanner';
|
||||
|
||||
/**
|
||||
* Server-side component to resolve content refs and pass down to client-side component
|
||||
*/
|
||||
export async function Announcement(props: {
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { context } = props;
|
||||
const { customization } = context;
|
||||
|
||||
if (
|
||||
!customization.announcement ||
|
||||
!customization.announcement.enabled ||
|
||||
!customization.announcement.message
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedContentRef = customization.announcement?.link
|
||||
? await resolveContentRef(customization.announcement?.link?.to, context)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnnouncementBanner
|
||||
announcement={customization.announcement}
|
||||
contentRef={resolvedContentRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user