mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 22:09:04 +00:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 938125e772 | |||
| 1c8d9febb3 | |||
| 223edae17d | |||
| 12a455dab5 | |||
| d00dc8ca72 | |||
| 2c85a302fc | |||
| 3460b78b45 | |||
| 95a1f652f9 | |||
| cc37e2ae15 | |||
| 08382ce62f | |||
| 80cb52a237 | |||
| c6637b09e2 | |||
| 778624af00 | |||
| e15757d01f | |||
| aed79fd6c3 | |||
| 0c973a3292 | |||
| 56c923c0ed | |||
| 7d7806df30 | |||
| cb5598dc19 | |||
| 47f01edcb8 | |||
| e6ddc0f07f | |||
| 373f18f451 | |||
| 3f292065cd | |||
| 5e975ab95b | |||
| a3ec264764 | |||
| 5d504ffa4c | |||
| 04999662db | |||
| f7a34706c7 | |||
| f328a41982 | |||
| 20ebecb114 | |||
| 5a69692f54 | |||
| 42ca7e1580 | |||
| c4ebb3fecd | |||
| 2a805cc082 | |||
| 89a5816ee4 | |||
| c3f6b8c003 | |||
| 0e201d5899 | |||
| ae5f1abc58 | |||
| 580101d04e | |||
| 81b4a4db53 | |||
| 8339e91e2b | |||
| 3119066728 | |||
| 326e28e9b0 | |||
| 90f0127ada | |||
| 0353a8186c | |||
| dd043df00f | |||
| 97b7c79bc6 | |||
| 634e0b4302 | |||
| 2a23f1f003 | |||
| 88ffdcc303 | |||
| b6b09d42af | |||
| ebc39e9db9 | |||
| 0df3849ea2 | |||
| 8ed1bda2f2 | |||
| d67699a1f1 | |||
| 3363a18856 | |||
| ad1dc0b914 | |||
| e4c7307c99 | |||
| f9c942c8ff | |||
| eeb977fc03 | |||
| 4b8a62122a | |||
| e44e947fc1 | |||
| 7588cfe1c5 | |||
| a9a643f222 | |||
| 77d6cb75be | |||
| 1942d221df | |||
| 041601ed65 | |||
| 5567281a85 | |||
| 4c3753046b | |||
| 89f8e16184 | |||
| a3e1125ced | |||
| 4dae8acfed | |||
| bc081c4344 | |||
| e4275b235d | |||
| 77397cac82 | |||
| b6a08bc679 | |||
| 116575c811 | |||
| 4ba7bd5ce1 | |||
| 223ab5377f | |||
| ae43ca146c | |||
| 42342893d6 | |||
| eedefdd5c9 | |||
| f5e152dd74 | |||
| 95ea22d9c5 | |||
| f23023ddfa | |||
| da74077954 | |||
| 29aaba5949 | |||
| daf41fc728 | |||
| b08eb1af59 | |||
| 8322cd3e35 | |||
| 444039c142 | |||
| 3b0eff7f71 | |||
| c765463e38 | |||
| e8ca25b67e | |||
| 528eee3fc6 | |||
| 168a4fa998 | |||
| 3ea75adba4 | |||
| cbd768a095 | |||
| 2d01653a78 | |||
| aa3357a57d | |||
| de53946896 | |||
| d51204148e | |||
| 319761a39e | |||
| 7fbf01a348 | |||
| c22490bd80 | |||
| 860fd6d02e | |||
| c43c524804 | |||
| 66bb938d85 | |||
| 0e6d928496 | |||
| f07982d9f8 | |||
| b92ecfad58 | |||
| 416bde70bc | |||
| 580f7ade15 | |||
| cdffd7c47b | |||
| 5a959cf041 | |||
| 31466546a2 | |||
| 90ead98eaa | |||
| 65e9cb9d5c | |||
| e59076a074 | |||
| 653920dec9 | |||
| 23cedd2d8b | |||
| 70c4182fd8 | |||
| b62b101065 | |||
| b9c929beeb | |||
| 2b6c593289 | |||
| c0b339e406 | |||
| da485f5144 | |||
| 139a8050a5 | |||
| da7b369ffd | |||
| 432da64919 | |||
| 7d0b422200 | |||
| fb90eb0acc |
@@ -63,9 +63,9 @@ To start your local version of GitBook, run the command `bun dev`.
|
|||||||
|
|
||||||
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
||||||
|
|
||||||
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/gitbook.com/docs` after running the development server.
|
||||||
|
|
||||||
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://gitbook.com/docs/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
||||||
|
|
||||||
### Commit your update
|
### Commit your update
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ inputs:
|
|||||||
deploy:
|
deploy:
|
||||||
description: 'Deploy as main version for all traffic instead of uploading versions'
|
description: 'Deploy as main version for all traffic instead of uploading versions'
|
||||||
required: true
|
required: true
|
||||||
|
commitTag:
|
||||||
|
description: 'Commit branch to associate with the deployment'
|
||||||
|
required: true
|
||||||
|
commitMessage:
|
||||||
|
description: 'Commit message to associate with the deployment'
|
||||||
|
required: true
|
||||||
outputs:
|
outputs:
|
||||||
deployment-url:
|
deployment-url:
|
||||||
description: "Deployment URL"
|
description: "Deployment URL"
|
||||||
@@ -49,10 +55,13 @@ runs:
|
|||||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
- name: Build worker
|
- name: Build worker
|
||||||
run: bun run turbo build:v2:cloudflare
|
run: bun run turbo build:v2:cloudflare
|
||||||
|
env:
|
||||||
|
GITBOOK_RUNTIME: cloudflare
|
||||||
shell: bash
|
shell: bash
|
||||||
- id: deploy
|
- id: deploy
|
||||||
name: Deploy to Cloudflare
|
name: Deploy to Cloudflare
|
||||||
@@ -61,9 +70,9 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '3.112.0'
|
wranglerVersion: '4.10.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: ${{ fromJSON(inputs.deploy) == true && 'deploy' || 'versions upload' }} --config ./packages/gitbook-v2/wrangler.toml
|
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ runs:
|
|||||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||||
- name: Build Project Artifacts
|
- name: Build Project Artifacts
|
||||||
@@ -62,6 +63,7 @@ runs:
|
|||||||
env:
|
env:
|
||||||
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
|
||||||
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
|
||||||
|
GITBOOK_RUNTIME: vercel
|
||||||
- name: Deploy Project Artifacts to Vercel
|
- name: Deploy Project Artifacts to Vercel
|
||||||
id: deploy
|
id: deploy
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ jobs:
|
|||||||
deploy-v1-cloudflare:
|
deploy-v1-cloudflare:
|
||||||
name: Deploy v1 to Cloudflare Pages
|
name: Deploy v1 to Cloudflare Pages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
|
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
|
||||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
permissions:
|
permissions:
|
||||||
@@ -40,6 +40,7 @@ jobs:
|
|||||||
run: bun run turbo gitbook#build:cloudflare
|
run: bun run turbo gitbook#build:cloudflare
|
||||||
env:
|
env:
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||||
|
GITBOOK_RUNTIME: cloudflare
|
||||||
- id: deploy
|
- id: deploy
|
||||||
name: Deploy to Cloudflare
|
name: Deploy to Cloudflare
|
||||||
uses: cloudflare/wrangler-action@v3.14.0
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
@@ -56,7 +57,7 @@ jobs:
|
|||||||
deploy-v2-vercel:
|
deploy-v2-vercel:
|
||||||
name: Deploy v2 to Vercel (preview)
|
name: Deploy v2 to Vercel (preview)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
name: 2v-preview
|
name: 2v-preview
|
||||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
outputs:
|
outputs:
|
||||||
@@ -77,11 +78,11 @@ jobs:
|
|||||||
deploy-v2-cloudflare:
|
deploy-v2-cloudflare:
|
||||||
name: Deploy v2 to Cloudflare Worker (preview)
|
name: Deploy v2 to Cloudflare Worker (preview)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
name: 2c-preview
|
name: 2c-preview
|
||||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||||
outputs:
|
outputs:
|
||||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -95,9 +96,19 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
opItem: op://gitbook-open/2c-preview
|
opItem: op://gitbook-open/2c-preview
|
||||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
|
- name: Extract Worker ID
|
||||||
|
id: extract-worker-id
|
||||||
|
if: ${{ !steps.deploy.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
|
||||||
|
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
|
||||||
|
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
|
||||||
comment-deployments:
|
comment-deployments:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Comment Deployments (preview)
|
name: Comment Deployments (preview)
|
||||||
@@ -123,14 +134,14 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
Summary of the deployments:
|
Summary of the deployments:
|
||||||
|
|
||||||
### Version 1 (production)
|
### Version 1
|
||||||
|
|
||||||
| Version | URL | Status |
|
| Version | URL | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
|
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||||
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
|
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||||
|
|
||||||
### Version 2 (experimental)
|
### Version 2
|
||||||
|
|
||||||
| Version | URL | Status |
|
| Version | URL | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -139,10 +150,10 @@ jobs:
|
|||||||
|
|
||||||
### Test content
|
### Test content
|
||||||
|
|
||||||
| Site | v1 | v2 |
|
| Site | `v1` | `2v` | `2c` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) |
|
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.com/docs) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.com/docs) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.com/docs) |
|
||||||
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||||
edit-mode: replace
|
edit-mode: replace
|
||||||
visual-testing-v1:
|
visual-testing-v1:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -184,11 +195,32 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'v2-vercel'
|
ARGOS_BUILD_NAME: 'v2-vercel'
|
||||||
|
visual-testing-v2-cloudflare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing v2 (Cloudflare)
|
||||||
|
needs: deploy-v2-cloudflare
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Setup Playwright
|
||||||
|
uses: ./.github/actions/setup-playwright
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||||
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'v2-cloudflare'
|
||||||
visual-testing-customers-v1:
|
visual-testing-customers-v1:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v1
|
name: Visual Testing Customers v1
|
||||||
needs: deploy-v1-cloudflare
|
needs: deploy-v1-cloudflare
|
||||||
timeout-minutes: 6
|
timeout-minutes: 8
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -208,7 +240,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2
|
name: Visual Testing Customers v2
|
||||||
needs: deploy-v2-vercel
|
needs: deploy-v2-vercel
|
||||||
timeout-minutes: 6
|
timeout-minutes: 8
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -225,6 +257,27 @@ jobs:
|
|||||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
ARGOS_BUILD_NAME: 'customers-v2'
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
|
visual-testing-customers-v2-cloudflare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Visual Testing Customers v2 (Cloudflare)
|
||||||
|
needs: deploy-v2-cloudflare
|
||||||
|
timeout-minutes: 8
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/composite/setup-bun
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Setup Playwright
|
||||||
|
uses: ./.github/actions/setup-playwright
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: bun e2e-customers
|
||||||
|
env:
|
||||||
|
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||||
|
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||||
|
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||||
|
ARGOS_BUILD_NAME: 'customers-v2'
|
||||||
pagespeed-testing-v1:
|
pagespeed-testing-v1:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: PageSpeed Testing v1
|
name: PageSpeed Testing v1
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Deploy staging
|
- name: Deploy
|
||||||
id: deploy
|
id: deploy
|
||||||
uses: ./.github/composite/deploy-vercel
|
uses: ./.github/composite/deploy-vercel
|
||||||
with:
|
with:
|
||||||
@@ -48,6 +48,8 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
opItem: op://gitbook-open/2c-production
|
opItem: op://gitbook-open/2c-production
|
||||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: main
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Deploy staging
|
- name: Deploy
|
||||||
id: deploy
|
id: deploy
|
||||||
uses: ./.github/composite/deploy-vercel
|
uses: ./.github/composite/deploy-vercel
|
||||||
with:
|
with:
|
||||||
@@ -48,6 +48,8 @@ jobs:
|
|||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
opItem: op://gitbook-open/2c-staging
|
opItem: op://gitbook-open/2c-staging
|
||||||
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||||
|
commitTag: main
|
||||||
|
commitMessage: ${{ github.sha }}
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||||
Vendored
+1
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports.biome": "explicit",
|
"source.organizeImports.biome": "explicit",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<h1 align="center">GitBook</h1>
|
<h1 align="center">GitBook</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://docs.gitbook.com/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
|
<a href="https://gitbook.com/docs/">Docs</a> - <a href="https://github.com/GitbookIO/community">Community</a> - <a href="https://developer.gitbook.com/">Developer Docs</a> - <a href="https://changelog.gitbook.com/">Changelog</a> - <a href="https://github.com/GitbookIO/gitbook/issues/new?assignees=&labels=bug&template=bug_report.md">Bug reports</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -59,15 +59,15 @@ bun install
|
|||||||
4. Start your local development server.
|
4. Start your local development server.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun dev
|
bun dev:v2
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
|
|
||||||
- http://localhost:3000/docs.gitbook.com
|
- http://localhost:3000/url/gitbook.com/docs
|
||||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
- http://localhost:3000/url/open-source.gitbook.io/midjourney
|
||||||
|
|
||||||
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,8 @@
|
|||||||
"**/.wrangler/**/*",
|
"**/.wrangler/**/*",
|
||||||
"packages/openapi-parser/src/fixtures/**/*",
|
"packages/openapi-parser/src/fixtures/**/*",
|
||||||
"packages/emoji-codepoints/index.ts",
|
"packages/emoji-codepoints/index.ts",
|
||||||
"packages/icons/src/data/*.json"
|
"packages/icons/src/data/*.json",
|
||||||
|
"packages/cache-do/worker-configuration.d.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
+5
-5
@@ -4,15 +4,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@changesets/cli": "^2.27.12",
|
"@changesets/cli": "^2.27.12",
|
||||||
"turbo": "^2.4.4",
|
"turbo": "^2.5.0",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.5",
|
"packageManager": "bun@1.2.11",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"react": "18.3.1",
|
"@gitbook/api": "^0.115.0",
|
||||||
"react-dom": "18.3.1",
|
"react": "^19.0.0",
|
||||||
"@gitbook/api": "0.106.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"wrangler": "^3.112.0"
|
"wrangler": "^4.10.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "wrangler types --experimental-include-runtime",
|
"generate": "wrangler types",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "tsc -w",
|
"dev": "tsc -w",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
"types": ["./worker-configuration.d.ts"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"generate": {
|
||||||
|
"outputs": ["worker-configuration.d.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# @gitbook/cache-tags
|
# @gitbook/cache-tags
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 77397ca: Fix version of @gitbook/api referenced in package.json
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
|
||||||
|
|
||||||
## 0.2.0
|
## 0.2.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.2.0",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "^0.115.0",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# @gitbook/colors
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- c3f6b8c: Update chroma ratio per step
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- cdffd7c: Desaturate text colors by decreasing chroma for the last steps of the color scale
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fb90eb0: Reduce chroma of first color scale step
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.5.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,13 +214,31 @@ export function colorScale(
|
|||||||
const targetL =
|
const targetL =
|
||||||
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
|
||||||
|
|
||||||
if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) {
|
if (
|
||||||
|
index === 8 &&
|
||||||
|
!mix &&
|
||||||
|
(darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2)
|
||||||
|
) {
|
||||||
// Original colour is close enough to target, so let's use the original colour as step 9.
|
// Original colour is close enough to target, so let's use the original colour as step 9.
|
||||||
result.push(hex);
|
result.push(hex);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1;
|
const chromaRatio = (() => {
|
||||||
|
switch (index) {
|
||||||
|
// Step 9 and 10 have max chroma, meaning they are fully saturated.
|
||||||
|
case 8:
|
||||||
|
case 9:
|
||||||
|
return 1;
|
||||||
|
// Step 11 and 12 have a reduced chroma
|
||||||
|
case 10:
|
||||||
|
return 0.4;
|
||||||
|
case 11:
|
||||||
|
return 0.1;
|
||||||
|
default:
|
||||||
|
return index * 0.05;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const shade = {
|
const shade = {
|
||||||
L: targetL, // Blend lightness
|
L: targetL, // Blend lightness
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
# cloudflare
|
# cloudflare
|
||||||
.open-next
|
.open-next
|
||||||
|
.wrangler
|
||||||
|
|
||||||
# Symbolic links
|
# Symbolic links
|
||||||
public
|
public
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
# gitbook-v2
|
# gitbook-v2
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 3119066: Add support for reusable content across spaces.
|
||||||
|
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 1c8d9fe: keep data cache in OpenNext between deployment
|
||||||
|
- 778624a: Only resize images with supported extensions.
|
||||||
|
- e6ddc0f: Fix URL in sitemap
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- e15757d: Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
|
||||||
|
- 634e0b4: Improve error messages around undefined site sections.
|
||||||
|
- 97b7c79: Increase logging around caching behaviour causing page crashes.
|
||||||
|
- 3f29206: Update the regex for validating site redirect
|
||||||
|
- dd043df: Revert investigation work around URL caches.
|
||||||
|
|
||||||
|
## 0.2.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [77397ca]
|
||||||
|
- @gitbook/cache-tags@0.3.1
|
||||||
|
|
||||||
|
## 0.2.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 4234289: Fix incoming URL for requests that were proxied
|
||||||
|
- Updated dependencies [116575c]
|
||||||
|
- @gitbook/cache-tags@0.3.0
|
||||||
|
|
||||||
## 0.2.3
|
## 0.2.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||||
authInterrupts: true,
|
authInterrupts: true,
|
||||||
|
|
||||||
// This is needed to use 'use cache'
|
|
||||||
useCache: true,
|
useCache: true,
|
||||||
|
|
||||||
// Content is fully static, we can cache it in the session memory cache for a long time
|
// Content is fully static, we can cache it in the session memory cache for a long time
|
||||||
@@ -33,7 +31,9 @@ const nextConfig = {
|
|||||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||||
|
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
|
||||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||||
|
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||||
|
|
||||||
// Next.js envs
|
// Next.js envs
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||||
@@ -53,6 +53,24 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/~gitbook/static/:path*',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Access-Control-Allow-Origin',
|
||||||
|
value: '*',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
||||||
import d1TagCache from '@opennextjs/cloudflare/d1-tag-cache';
|
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||||
import kvIncrementalCache from '@opennextjs/cloudflare/kv-cache';
|
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||||
import memoryQueue from '@opennextjs/cloudflare/memory-queue';
|
import {
|
||||||
|
softTagFilter,
|
||||||
|
withFilter,
|
||||||
|
} from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
export default defineCloudflareConfig({
|
||||||
incrementalCache: kvIncrementalCache,
|
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||||
queue: memoryQueue,
|
tagCache: withFilter({
|
||||||
tagCache: d1TagCache,
|
tagCache: doShardedTagCache({
|
||||||
|
baseShardSize: 12,
|
||||||
|
regionalCache: true,
|
||||||
|
shardReplication: {
|
||||||
|
numberOfSoftReplicas: 2,
|
||||||
|
numberOfHardReplicas: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// We don't use `revalidatePath`, so we filter out soft tags
|
||||||
|
filterFn: softTagFilter,
|
||||||
|
}),
|
||||||
|
queue: doQueue,
|
||||||
|
|
||||||
|
// Performance improvements as we don't use PPR
|
||||||
|
enableCacheInterception: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import { trace } from '@/lib/tracing';
|
||||||
|
import type {
|
||||||
|
CacheEntryType,
|
||||||
|
CacheValue,
|
||||||
|
IncrementalCache,
|
||||||
|
WithLastModified,
|
||||||
|
} from '@opennextjs/aws/types/overrides.js';
|
||||||
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
|
|
||||||
|
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
||||||
|
export const DEFAULT_PREFIX = 'incremental-cache';
|
||||||
|
|
||||||
|
export type KeyOptions = {
|
||||||
|
cacheType?: CacheEntryType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it allow us to trace
|
||||||
|
* the cache operations. It also integrates both R2 and Cache API in a single class.
|
||||||
|
* Having our own, will allow us to customize it in the future if needed.
|
||||||
|
*/
|
||||||
|
class GitbookIncrementalCache implements IncrementalCache {
|
||||||
|
name = 'GitbookIncrementalCache';
|
||||||
|
|
||||||
|
protected localCache: Cache | undefined;
|
||||||
|
|
||||||
|
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||||
|
key: string,
|
||||||
|
cacheType?: CacheType
|
||||||
|
): Promise<WithLastModified<CacheValue<CacheType>> | null> {
|
||||||
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'openNextIncrementalCacheGet',
|
||||||
|
name: cacheKey,
|
||||||
|
},
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
try {
|
||||||
|
// Check local cache first if available
|
||||||
|
const localCacheEntry = await localCache.match(this.getCacheUrlKey(cacheKey));
|
||||||
|
if (localCacheEntry) {
|
||||||
|
span.setAttribute('cacheHit', 'local');
|
||||||
|
return localCacheEntry.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const r2Object = await r2.get(cacheKey);
|
||||||
|
if (!r2Object) return null;
|
||||||
|
|
||||||
|
span.setAttribute('cacheHit', 'r2');
|
||||||
|
return {
|
||||||
|
value: await r2Object.json(),
|
||||||
|
lastModified: r2Object.uploaded.getTime(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get from cache', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<CacheType extends CacheEntryType = 'cache'>(
|
||||||
|
key: string,
|
||||||
|
value: CacheValue<CacheType>,
|
||||||
|
cacheType?: CacheType
|
||||||
|
): Promise<void> {
|
||||||
|
const cacheKey = this.getR2Key(key, cacheType);
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'openNextIncrementalCacheSet',
|
||||||
|
name: cacheKey,
|
||||||
|
},
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await r2.put(cacheKey, JSON.stringify(value));
|
||||||
|
|
||||||
|
//TODO: Check if there is any places where we don't have tags
|
||||||
|
// Ideally we should always have tags, but in case we don't, we need to decide how to handle it
|
||||||
|
// For now we default to a build ID tag, which allow us to invalidate the cache in case something is wrong in this deployment
|
||||||
|
const tags = this.getTagsFromCacheEntry(value) ?? [
|
||||||
|
`build_id/${process.env.NEXT_BUILD_ID}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// We consider R2 as the source of truth, so we update the local cache
|
||||||
|
// only after a successful R2 write
|
||||||
|
await localCache.put(
|
||||||
|
this.getCacheUrlKey(cacheKey),
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
value,
|
||||||
|
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
|
||||||
|
// See https://developers.cloudflare.com/workers/reference/security-model/
|
||||||
|
lastModified: Date.now(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// Cache-Control default to 30 minutes, will be overridden by `revalidate`
|
||||||
|
// In theory we should always get the `revalidate` value
|
||||||
|
'cache-control': `max-age=${value.revalidate ?? 60 * 30}`,
|
||||||
|
'cache-tag': tags.join(','),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set to cache', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
const cacheKey = this.getR2Key(key);
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'openNextIncrementalCacheDelete',
|
||||||
|
name: cacheKey,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const r2 = getCloudflareContext().env[BINDING_NAME];
|
||||||
|
const localCache = await this.getCacheInstance();
|
||||||
|
if (!r2) throw new Error('No R2 bucket');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await r2.delete(cacheKey);
|
||||||
|
|
||||||
|
// Here again R2 is the source of truth, so we delete from local cache first
|
||||||
|
await localCache.delete(this.getCacheUrlKey(cacheKey));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete from cache', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCacheInstance(): Promise<Cache> {
|
||||||
|
if (this.localCache) return this.localCache;
|
||||||
|
this.localCache = await caches.open('incremental-cache');
|
||||||
|
return this.localCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to generate keys for R2/Cache API
|
||||||
|
getR2Key(key: string, cacheType: CacheEntryType = 'cache'): string {
|
||||||
|
const hash = createHash('sha256').update(key).digest('hex');
|
||||||
|
return `${DEFAULT_PREFIX}/${cacheType === 'cache' ? process.env?.NEXT_BUILD_ID : 'dataCache'}/${hash}.${cacheType}`.replace(
|
||||||
|
/\/+/g,
|
||||||
|
'/'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheUrlKey(cacheKey: string): string {
|
||||||
|
return `http://cache.local/${cacheKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTagsFromCacheEntry<CacheType extends CacheEntryType>(
|
||||||
|
entry: CacheValue<CacheType>
|
||||||
|
): string[] | undefined {
|
||||||
|
if ('tags' in entry && entry.tags) {
|
||||||
|
return entry.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('meta' in entry && entry.meta && 'headers' in entry.meta && entry.meta.headers) {
|
||||||
|
const rawTags = entry.meta.headers['x-next-cache-tags'];
|
||||||
|
if (typeof rawTags === 'string') {
|
||||||
|
return rawTags.split(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('value' in entry) {
|
||||||
|
return entry.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new GitbookIncrementalCache();
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook-v2",
|
"name": "gitbook-v2",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.2.3",
|
"@gitbook/api": "^0.115.0",
|
||||||
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
|
"@opennextjs/cloudflare": "1.0.3",
|
||||||
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
|
"assert-never": "^1.2.1",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"next": "^15.3.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@gitbook/api": "*",
|
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"warn-once": "^0.1.1",
|
|
||||||
"rison": "^0.1.1",
|
"rison": "^0.1.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"server-only": "^0.0.1",
|
||||||
"p-memoize": "^7.1.1"
|
"warn-once": "^0.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gitbook": "*",
|
"gitbook": "*",
|
||||||
"@opennextjs/cloudflare": "^0.5.10",
|
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"postcss": "^8"
|
"postcss": "^8"
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:v2": "next build",
|
"build:v2": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build:v2:cloudflare": "opennextjs-cloudflare",
|
"build:v2:cloudflare": "opennextjs-cloudflare build",
|
||||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||||
"unit": "bun test",
|
"unit": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-11
@@ -3,10 +3,9 @@ import {
|
|||||||
generateSitePageMetadata,
|
generateSitePageMetadata,
|
||||||
generateSitePageViewport,
|
generateSitePageViewport,
|
||||||
} from '@/components/SitePage';
|
} from '@/components/SitePage';
|
||||||
import { getCacheTag } from '@gitbook/cache-tags';
|
|
||||||
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
@@ -15,19 +14,10 @@ type PageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
'use cache';
|
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getStaticSiteContext(params);
|
const { context } = await getStaticSiteContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
cacheTag(
|
|
||||||
getCacheTag({
|
|
||||||
tag: 'site',
|
|
||||||
site: context.site.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
generateSiteLayoutMetadata,
|
generateSiteLayoutMetadata,
|
||||||
generateSiteLayoutViewport,
|
generateSiteLayoutViewport,
|
||||||
} from '@/components/SiteLayout';
|
} from '@/components/SiteLayout';
|
||||||
import { getCacheTag } from '@gitbook/cache-tags';
|
|
||||||
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
|
||||||
|
|
||||||
interface SiteStaticLayoutProps {
|
interface SiteStaticLayoutProps {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
@@ -17,17 +15,8 @@ export default async function SiteStaticLayout({
|
|||||||
params,
|
params,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||||
'use cache';
|
|
||||||
|
|
||||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||||
|
|
||||||
cacheTag(
|
|
||||||
getCacheTag({
|
|
||||||
tag: 'site',
|
|
||||||
site: context.site.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout customization={context.customization}>
|
<CustomizationRootLayout customization={context.customization}>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getDataOrNull,
|
getDataOrNull,
|
||||||
throwIfDataError,
|
throwIfDataError,
|
||||||
} from '@v2/lib/data';
|
} from '@v2/lib/data';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { assert } from 'ts-essentials';
|
import { assert } from 'ts-essentials';
|
||||||
import { GITBOOK_URL } from './env';
|
import { GITBOOK_URL } from './env';
|
||||||
@@ -242,19 +243,45 @@ export async function fetchSiteContextByIds(
|
|||||||
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const siteSpace = (
|
// Parse the current siteSpace and siteSpaces based on the site structure type.
|
||||||
siteStructure.type === 'siteSpaces' && siteStructure.structure
|
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
|
||||||
? siteStructure.structure
|
if (siteStructure.type === 'siteSpaces') {
|
||||||
: sections?.current.siteSpaces
|
const siteSpaces = siteStructure.structure;
|
||||||
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||||
if (!siteSpace) {
|
|
||||||
throw new Error('Site space not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const siteSpaces =
|
if (!siteSpace) {
|
||||||
siteStructure.type === 'siteSpaces'
|
throw new Error(
|
||||||
? siteStructure.structure
|
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
|
||||||
: (sections?.current.siteSpaces ?? []);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { siteSpaces, siteSpace };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteStructure.type === 'sections') {
|
||||||
|
assert(
|
||||||
|
sections,
|
||||||
|
`cannot find site space "${ids.siteSpace}" because parsed sections are missing siteStructure.type="sections" siteSection="${ids.siteSection}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSection = sections.current;
|
||||||
|
const siteSpaces = currentSection.siteSpaces;
|
||||||
|
const siteSpace = currentSection.siteSpaces.find(
|
||||||
|
(siteSpace) => siteSpace.id === ids.siteSpace
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!siteSpace) {
|
||||||
|
throw new Error(
|
||||||
|
`Site space "${ids.siteSpace}" not found in structure type="sections" currentSection="${currentSection.id}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { siteSpaces, siteSpace };
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
assertNever(siteStructure, `cannot handle site structure of type ${siteStructure.type}`);
|
||||||
|
})();
|
||||||
|
|
||||||
const customization = (() => {
|
const customization = (() => {
|
||||||
if (ids.siteSpace) {
|
if (ids.siteSpace) {
|
||||||
@@ -380,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
|
|||||||
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||||
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
||||||
const section = parseCurrentSection(structure, siteSectionId);
|
const section = parseCurrentSection(structure, siteSectionId);
|
||||||
assert(section, 'A section must be defined when there are multiple sections');
|
assert(section, `couldn't find section "${siteSectionId}" in site structure`);
|
||||||
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
|
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
import { getCloudflareContext as getCloudflareContextOpenNext } from '@opennextjs/cloudflare';
|
||||||
|
import { GITBOOK_RUNTIME } from '../env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Cloudflare context or null when not running in Cloudflare.
|
||||||
|
*/
|
||||||
|
export function getCloudflareContext() {
|
||||||
|
if (GITBOOK_RUNTIME !== 'cloudflare') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCloudflareContextOpenNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an object representing the current request.
|
||||||
|
*/
|
||||||
|
export function getCloudflareRequestGlobal() {
|
||||||
|
const context = getCloudflareContext();
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.cf;
|
||||||
|
}
|
||||||
@@ -98,6 +98,10 @@ export async function wrapDataFetcherError<T>(
|
|||||||
*/
|
*/
|
||||||
export function getExposableError(error: Error): DataFetcherErrorData {
|
export function getExposableError(error: Error): DataFetcherErrorData {
|
||||||
if (error instanceof GitBookAPIError) {
|
if (error instanceof GitBookAPIError) {
|
||||||
|
if (error.code >= 500) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: error.errorMessage,
|
message: error.errorMessage,
|
||||||
@@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof DataFetcherError) {
|
if (error instanceof DataFetcherError) {
|
||||||
|
if (error.code >= 500) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export * from './pages';
|
|||||||
export * from './urls';
|
export * from './urls';
|
||||||
export * from './errors';
|
export * from './errors';
|
||||||
export * from './lookup';
|
export * from './lookup';
|
||||||
|
export * from './proxy';
|
||||||
|
export * from './visitor';
|
||||||
|
|||||||
@@ -1,55 +1,102 @@
|
|||||||
import { race, tryCatch } from '@/lib/async';
|
import { race, tryCatch } from '@/lib/async';
|
||||||
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||||
import { trace } from '@/lib/tracing';
|
import { trace } from '@/lib/tracing';
|
||||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
|
||||||
import { apiClient } from './api';
|
import { apiClient } from './api';
|
||||||
import { getExposableError } from './errors';
|
import { getExposableError } from './errors';
|
||||||
import type { DataFetcherResponse } from './types';
|
import type { DataFetcherResponse } from './types';
|
||||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||||
|
|
||||||
/**
|
interface LookupPublishedContentByUrlInput {
|
||||||
* Lookup a content by its URL using the GitBook API.
|
|
||||||
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
|
||||||
*/
|
|
||||||
export async function getPublishedContentByURL(input: {
|
|
||||||
url: string;
|
url: string;
|
||||||
visitorAuthToken: string | null;
|
|
||||||
redirectOnError: boolean;
|
redirectOnError: boolean;
|
||||||
apiToken: string | null;
|
apiToken: string | null;
|
||||||
|
visitorPayload: SiteVisitorPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
|
||||||
|
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||||
|
*/
|
||||||
|
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
|
||||||
|
return lookupPublishedContentByUrl({
|
||||||
|
url: input.url,
|
||||||
|
fetchLookupAPIResult: ({ url, signal }) => {
|
||||||
|
const api = apiClient({ apiToken: input.apiToken });
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'resolvePublishedContentByUrl',
|
||||||
|
name: url,
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
tryCatch(
|
||||||
|
api.urls.resolvePublishedContentByUrl(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
|
||||||
|
redirectOnError: input.redirectOnError,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
|
||||||
|
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||||
|
*
|
||||||
|
* @deprecated use resolvePublishedContentByUrl.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
|
||||||
|
return lookupPublishedContentByUrl({
|
||||||
|
url: input.url,
|
||||||
|
fetchLookupAPIResult: ({ url, signal }) => {
|
||||||
|
const api = apiClient({ apiToken: input.apiToken });
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'getPublishedContentByURL',
|
||||||
|
name: url,
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
tryCatch(
|
||||||
|
api.urls.getPublishedContentByUrl(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
|
||||||
|
redirectOnError: input.redirectOnError,
|
||||||
|
// @ts-expect-error - cacheVersion is not a real query param
|
||||||
|
cacheVersion: 'v2',
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
|
||||||
|
|
||||||
|
async function lookupPublishedContentByUrl(input: {
|
||||||
|
url: string;
|
||||||
|
fetchLookupAPIResult: (args: {
|
||||||
|
url: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
|
||||||
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||||
const lookupURL = new URL(input.url);
|
const lookupURL = new URL(input.url);
|
||||||
const url = stripURLSearch(lookupURL);
|
const url = stripURLSearch(lookupURL);
|
||||||
const lookup = getURLLookupAlternatives(url);
|
const lookup = getURLLookupAlternatives(url);
|
||||||
|
|
||||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||||
const api = await apiClient({ apiToken: input.apiToken });
|
const callResult = await input.fetchLookupAPIResult({
|
||||||
|
url: alternative.url,
|
||||||
const callResult = await trace(
|
signal,
|
||||||
{
|
});
|
||||||
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 (callResult.error) {
|
||||||
if (alternative.primary) {
|
if (alternative.primary) {
|
||||||
|
|||||||
@@ -1,39 +1,52 @@
|
|||||||
import { describe, expect, it, mock } from 'bun:test';
|
import { describe, expect, it, mock } from 'bun:test';
|
||||||
import { memoize } from './memoize';
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
|
import { withCacheKey, withoutConcurrentExecution } from './memoize';
|
||||||
|
|
||||||
describe('memoize', () => {
|
describe('withoutConcurrentExecution', () => {
|
||||||
it('should memoize the function', async () => {
|
it('should memoize the function based on the cache key', async () => {
|
||||||
const fn = mock(async () => Math.random());
|
const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b);
|
||||||
const memoized = memoize(fn);
|
const memoized = withoutConcurrentExecution(() => null, fn);
|
||||||
expect(await memoized()).toBe(await memoized());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should memoize the function with different arguments', async () => {
|
const p1 = memoized('c1', 1, 2);
|
||||||
const fn = mock(async (a: number, b: number) => a + b);
|
const p2 = memoized('c1', 1, 2);
|
||||||
const memoized = memoize(fn);
|
const p3 = memoized('c3', 2, 3);
|
||||||
expect(await memoized(1, 2)).toBe(await memoized(1, 2));
|
|
||||||
expect(fn.mock.calls.length).toBe(1);
|
expect(await p1).toBe(await p2);
|
||||||
expect(await memoized(1, 2)).not.toBe(await memoized(2, 3));
|
expect(await p1).not.toBe(await p3);
|
||||||
expect(fn.mock.calls.length).toBe(2);
|
expect(fn.mock.calls.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should memoize a function complex object', async () => {
|
it('should support caching per request', 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 fn = mock(async () => Math.random());
|
||||||
const memoized = memoize(fn);
|
|
||||||
const promise1 = memoized();
|
const request1 = { id: 'request1' };
|
||||||
const promise2 = memoized();
|
const request2 = { id: 'request2' };
|
||||||
|
|
||||||
|
const requestContext = new AsyncLocalStorage<{ id: string }>();
|
||||||
|
|
||||||
|
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn);
|
||||||
|
|
||||||
|
// Both in the same request
|
||||||
|
const promise1 = requestContext.run(request1, () => memoized('c1'));
|
||||||
|
const promise2 = requestContext.run(request1, () => memoized('c1'));
|
||||||
|
|
||||||
|
// In a different request
|
||||||
|
const promise3 = requestContext.run(request2, () => memoized('c1'));
|
||||||
|
|
||||||
expect(await promise1).toBe(await promise2);
|
expect(await promise1).toBe(await promise2);
|
||||||
|
expect(await promise1).not.toBe(await promise3);
|
||||||
|
expect(fn.mock.calls.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withCacheKey', () => {
|
||||||
|
it('should wrap the function by passing the cache key', async () => {
|
||||||
|
const fn = mock(
|
||||||
|
async (cacheKey: string, arg: { a: number; b: number }, c: number) =>
|
||||||
|
`${cacheKey}, result=${arg.a + arg.b + c}`
|
||||||
|
);
|
||||||
|
const memoized = withCacheKey(fn);
|
||||||
|
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
|
||||||
expect(fn.mock.calls.length).toBe(1);
|
expect(fn.mock.calls.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,61 @@
|
|||||||
import pMemoize from 'p-memoize';
|
/**
|
||||||
|
* Wrap a function by preventing concurrent executions of the same function.
|
||||||
|
* With a logic to work per-request in Cloudflare Workers.
|
||||||
|
*/
|
||||||
|
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
|
||||||
|
getGlobalContext: () => object | null | undefined,
|
||||||
|
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
|
||||||
|
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
|
||||||
|
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
|
||||||
|
|
||||||
|
return (key: string, ...args: ArgsType) => {
|
||||||
|
const globalContext = getGlobalContext() ?? globalThis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
|
||||||
|
* to avoid "Cannot perform I/O on behalf of a different request" errors.
|
||||||
|
*/
|
||||||
|
const promiseCache =
|
||||||
|
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
|
||||||
|
globalPromiseCache.set(globalContext, promiseCache);
|
||||||
|
|
||||||
|
const concurrent = promiseCache.get(key);
|
||||||
|
if (concurrent) {
|
||||||
|
return concurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const result = await wrapped(key, ...args);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
promiseCache.delete(key);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
promiseCache.set(key, promise);
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We wrap 'use cache' calls in a p-memoize function to avoid
|
* Wrap a function by passing it a cache key that is computed from the function arguments.
|
||||||
* 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 {
|
export function withCacheKey<ArgsType extends any[], ReturnType>(
|
||||||
return pMemoize(f, {
|
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
|
||||||
cacheKey: (args) => {
|
): (...args: ArgsType) => Promise<ReturnType> {
|
||||||
return JSON.stringify(deepSortValue(args));
|
return (...args: ArgsType) => {
|
||||||
},
|
const cacheKey = getCacheKey(args);
|
||||||
});
|
return wrapped(cacheKey, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a cache key from the function arguments.
|
||||||
|
*/
|
||||||
|
function getCacheKey(args: any[]) {
|
||||||
|
return JSON.stringify(deepSortValue(args));
|
||||||
}
|
}
|
||||||
|
|
||||||
function deepSortValue(value: unknown): unknown {
|
function deepSortValue(value: unknown): unknown {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
|
||||||
|
|
||||||
|
describe('isProxyRequest', () => {
|
||||||
|
it('should return true for proxy requests', () => {
|
||||||
|
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
|
||||||
|
expect(isProxyRequest(proxyRequestURL)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-proxy requests', () => {
|
||||||
|
const nonProxyRequestURL = new URL('https://example.com/docs/foo/hello/world');
|
||||||
|
expect(isProxyRequest(nonProxyRequestURL)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getProxyRequestIdentifier', () => {
|
||||||
|
it('should return the correct identifier for proxy requests', () => {
|
||||||
|
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
|
||||||
|
expect(getProxyRequestIdentifier(proxyRequestURL)).toBe('sites/site_foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Check if the request to the site was through a proxy.
|
||||||
|
*/
|
||||||
|
export function isProxyRequest(requestURL: URL): boolean {
|
||||||
|
return (
|
||||||
|
requestURL.host === 'proxy.gitbook.site' || requestURL.host === 'proxy.gitbook-staging.site'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProxyRequestIdentifier(requestURL: URL): string {
|
||||||
|
// For proxy requests, we extract the site ID from the pathname
|
||||||
|
// e.g. https://proxy.gitbook.site/site/siteId/...
|
||||||
|
const pathname = requestURL.pathname.slice(1).split('/');
|
||||||
|
return pathname.slice(0, 2).join('/');
|
||||||
|
}
|
||||||
@@ -179,4 +179,15 @@ export interface GitBookDataFetcher {
|
|||||||
integrationName: string;
|
integrationName: string;
|
||||||
request: api.RenderIntegrationUI;
|
request: api.RenderIntegrationUI;
|
||||||
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
|
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream an AI response.
|
||||||
|
*/
|
||||||
|
streamAIResponse(params: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
input: api.AIMessageInput[];
|
||||||
|
output: api.AIOutputFormat;
|
||||||
|
model: api.AIModel;
|
||||||
|
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { getVisitorAuthBasePath } from './visitor';
|
||||||
|
|
||||||
|
describe('getVisitorAuthBasePath', () => {
|
||||||
|
it('should return the correct base path for proxy requests', () => {
|
||||||
|
expect(
|
||||||
|
getVisitorAuthBasePath(
|
||||||
|
new URL('https://proxy.gitbook.site/sites/site_foo/hello/world'),
|
||||||
|
{
|
||||||
|
site: 'site_foo',
|
||||||
|
siteSpace: 'sitesp_foo',
|
||||||
|
basePath: '/foo',
|
||||||
|
siteBasePath: '/foo',
|
||||||
|
organization: 'org_foo',
|
||||||
|
space: 'space_foo',
|
||||||
|
pathname: '/hello/world',
|
||||||
|
complete: false,
|
||||||
|
apiToken: 'api_token_foo',
|
||||||
|
canonicalUrl: 'https://example.com/docs/foo/hello/world',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).toBe('/sites/site_foo/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct base path for non-proxy requests', () => {
|
||||||
|
expect(
|
||||||
|
getVisitorAuthBasePath(new URL('https://example.com/docs/foo/hello/world'), {
|
||||||
|
site: 'site_foo',
|
||||||
|
siteSpace: 'sitesp_foo',
|
||||||
|
basePath: '/foo/',
|
||||||
|
siteBasePath: '/foo/',
|
||||||
|
organization: 'org_foo',
|
||||||
|
space: 'space_foo',
|
||||||
|
pathname: '/hello/world',
|
||||||
|
complete: false,
|
||||||
|
apiToken: 'api_token_foo',
|
||||||
|
canonicalUrl: 'https://example.com/docs/foo/hello/world',
|
||||||
|
})
|
||||||
|
).toBe('/foo/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||||
|
import type { PublishedSiteContent } from '@gitbook/api';
|
||||||
|
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate base path for the visitor authentication cookie.
|
||||||
|
*/
|
||||||
|
export function getVisitorAuthBasePath(
|
||||||
|
siteRequestURL: URL,
|
||||||
|
siteURLData: PublishedSiteContent
|
||||||
|
): string {
|
||||||
|
// The siteRequestURL for proxy requests is of the form `https://proxy.gitbook.com/site/siteId/...`
|
||||||
|
// In such cases, we should not use the resolved siteBasePath for the cookie because for subsequent requests
|
||||||
|
// we will not have the siteBasePath in the request URL in order to retrieve the cookie. So we use the
|
||||||
|
// proxy identifier instead.
|
||||||
|
return isProxyRequest(siteRequestURL)
|
||||||
|
? withLeadingSlash(withTrailingSlash(getProxyRequestIdentifier(siteRequestURL)))
|
||||||
|
: siteURLData.siteBasePath;
|
||||||
|
}
|
||||||
+26
@@ -6,6 +6,14 @@ import 'server-only';
|
|||||||
* and not from the `process.env` object.
|
* and not from the `process.env` object.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime environment.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as
|
||||||
|
| 'vercel'
|
||||||
|
| 'cloudflare'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main host on which GitBook is running.
|
* Main host on which GitBook is running.
|
||||||
*/
|
*/
|
||||||
@@ -77,6 +85,15 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
|
|||||||
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
||||||
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode used for resizing images.
|
||||||
|
*/
|
||||||
|
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
|
||||||
|
'GITBOOK_IMAGE_RESIZE_MODE',
|
||||||
|
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
|
||||||
|
['cdn-cgi', 'cf-fetch']
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint where icons are served.
|
* Endpoint where icons are served.
|
||||||
*/
|
*/
|
||||||
@@ -92,3 +109,12 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
|
|||||||
* Secret used to validate requests from the GitBook app.
|
* Secret used to validate requests from the GitBook app.
|
||||||
*/
|
*/
|
||||||
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
||||||
|
|
||||||
|
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
|
||||||
|
if (!enumValues.includes(value as T)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||||
|
|
||||||
|
describe('checkIsSizableImageURL', () => {
|
||||||
|
it('should return Skip for non-parsable URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Skip for non-http(s) URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip);
|
||||||
|
expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Skip for localhost URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe(
|
||||||
|
SizableImageAction.Skip
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Skip for GitBook image URLs', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe(
|
||||||
|
SizableImageAction.Skip
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Resize for supported image extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.png')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Resize for URLs without extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Passthrough for unsupported image extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with query parameters correctly', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe(
|
||||||
|
SizableImageAction.Passthrough
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive for extensions', () => {
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe(
|
||||||
|
SizableImageAction.Resize
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getExtension } from '@/lib/paths';
|
||||||
|
|
||||||
|
export enum SizableImageAction {
|
||||||
|
Resize = 'resize',
|
||||||
|
Skip = 'skip',
|
||||||
|
Passthrough = 'passthrough',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://developers.cloudflare.com/images/transform-images/#supported-input-formats
|
||||||
|
*/
|
||||||
|
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an image URL is resizable.
|
||||||
|
* Skip it for non-http(s) URLs (data, etc).
|
||||||
|
* Skip it for SVGs.
|
||||||
|
* Skip it for GitBook images (to avoid recursion).
|
||||||
|
*/
|
||||||
|
export function checkIsSizableImageURL(input: string): SizableImageAction {
|
||||||
|
if (!URL.canParse(input)) {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(input);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
if (parsed.hostname === 'localhost') {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
if (parsed.pathname.includes('/~gitbook/image')) {
|
||||||
|
return SizableImageAction.Skip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = getExtension(parsed.pathname).toLowerCase();
|
||||||
|
if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
|
||||||
|
// If no extension, we consider it resizable.
|
||||||
|
return SizableImageAction.Resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizableImageAction.Passthrough;
|
||||||
|
}
|
||||||
@@ -1,34 +1,11 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||||
import type { GitBookLinker } from '../links';
|
import type { GitBookLinker } from '../links';
|
||||||
|
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||||
|
import { getImageSize } from './resizer';
|
||||||
import { type SignatureVersion, generateImageSignature } from './signatures';
|
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||||
import type { ImageResizer } from './types';
|
import type { ImageResizer } from './types';
|
||||||
|
|
||||||
interface CloudflareImageJsonFormat {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
original: {
|
|
||||||
file_size: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
format: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
|
|
||||||
*/
|
|
||||||
export interface CloudflareImageOptions {
|
|
||||||
format?: 'webp' | 'avif' | 'json' | 'jpeg';
|
|
||||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
dpr?: number;
|
|
||||||
anim?: boolean;
|
|
||||||
quality?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an image resizer for a rendering context.
|
* Create an image resizer for a rendering context.
|
||||||
*/
|
*/
|
||||||
@@ -47,7 +24,7 @@ export function createImageResizer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getResizedImageURL: (urlInput) => {
|
getResizedImageURL: (urlInput) => {
|
||||||
if (!checkIsSizableImageURL(urlInput)) {
|
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +64,7 @@ export function createImageResizer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getImageSize: async (input, options) => {
|
getImageSize: async (input, options) => {
|
||||||
if (!checkIsSizableImageURL(input)) {
|
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,124 +83,6 @@ export function createNoopImageResizer(): ImageResizer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL is an HTTP URL.
|
|
||||||
*/
|
|
||||||
export function checkIsHttpURL(input: string | URL): boolean {
|
|
||||||
if (!URL.canParse(input)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const parsed = new URL(input);
|
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an image URL is resizable.
|
|
||||||
* Skip it for non-http(s) URLs (data, etc).
|
|
||||||
* Skip it for SVGs.
|
|
||||||
* Skip it for GitBook images (to avoid recursion).
|
|
||||||
*/
|
|
||||||
export function checkIsSizableImageURL(input: string): boolean {
|
|
||||||
if (!URL.canParse(input)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.includes('/~gitbook/image')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = new URL(input);
|
|
||||||
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!checkIsHttpURL(parsed)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the size of an image.
|
|
||||||
*/
|
|
||||||
export async function getImageSize(
|
|
||||||
input: string,
|
|
||||||
defaultSize: Partial<CloudflareImageOptions> = {}
|
|
||||||
): Promise<{ width: number; height: number } | null> {
|
|
||||||
if (!checkIsSizableImageURL(input)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await resizeImage(input, {
|
|
||||||
// Abort the request after 2 seconds to avoid blocking rendering for too long
|
|
||||||
signal: AbortSignal.timeout(2000),
|
|
||||||
// Measure size and resize it to the most common size
|
|
||||||
// to optimize caching
|
|
||||||
...defaultSize,
|
|
||||||
format: 'json',
|
|
||||||
anim: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = (await response.json()) as CloudflareImageJsonFormat;
|
|
||||||
return {
|
|
||||||
width: json.original.width,
|
|
||||||
height: json.original.height,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a Cloudflare Image Resize operation on an image.
|
|
||||||
*/
|
|
||||||
export async function resizeImage(
|
|
||||||
input: string,
|
|
||||||
options: CloudflareImageOptions & {
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
): Promise<Response> {
|
|
||||||
const { signal, ...resizeOptions } = options;
|
|
||||||
|
|
||||||
const parsed = new URL(input);
|
|
||||||
if (parsed.protocol === 'data:') {
|
|
||||||
throw new Error('Cannot resize data: URLs');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.hostname === 'localhost') {
|
|
||||||
throw new Error('Cannot resize localhost URLs');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
|
|
||||||
// we need to use the Cloudflare Image Resize API directly.
|
|
||||||
if (!GITBOOK_IMAGE_RESIZE_URL) {
|
|
||||||
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fetch(
|
|
||||||
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
|
|
||||||
resizeOptions
|
|
||||||
)}/${encodeURIComponent(input)}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
|
||||||
Accept:
|
|
||||||
resizeOptions.format === 'json'
|
|
||||||
? 'application/json'
|
|
||||||
: `image/${resizeOptions.format || 'jpeg'}`,
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyOptions(options: CloudflareImageOptions): string {
|
|
||||||
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
|
|
||||||
return `${rest}${rest ? ',' : ''}${key}=${value}`;
|
|
||||||
}, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
|
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
|
||||||
* We protect against it by converting to a special token, and then parsing
|
* We protect against it by converting to a special token, and then parsing
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
import { getProxyRequestIdentifier, isProxyRequest } from '../data';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the site identifier to use for image resizing for an incoming request.
|
* Get the site identifier to use for image resizing for an incoming request.
|
||||||
* This identifier can be obtained before resolving the request URL.
|
* This identifier can be obtained before resolving the request URL.
|
||||||
*/
|
*/
|
||||||
export function getImageResizingContextId(url: URL): string {
|
export function getImageResizingContextId(url: URL): string {
|
||||||
if (url.host === 'proxy.gitbook.site' || url.host === 'proxy.gitbook-staging.site') {
|
if (isProxyRequest(url)) {
|
||||||
// For proxy requests, we extract the site ID from the pathname
|
return getProxyRequestIdentifier(url);
|
||||||
// e.g. https://proxy.gitbook.site/site/siteId/...
|
|
||||||
const pathname = url.pathname.slice(1).split('/');
|
|
||||||
return pathname.slice(0, 2).join('/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.host;
|
return url.host;
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export * from './createImageResizer';
|
|||||||
export * from './signatures';
|
export * from './signatures';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './getImageResizingContextId';
|
export * from './getImageResizingContextId';
|
||||||
|
export * from './resizer';
|
||||||
|
export * from './checkIsSizableImageURL';
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
|
||||||
|
import type { CloudflareImageOptions } from './types';
|
||||||
|
import { copyImageResponse } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
|
||||||
|
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||||
|
*/
|
||||||
|
export async function resizeImageWithCDNCgi(
|
||||||
|
input: string,
|
||||||
|
options: CloudflareImageOptions & {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
): Promise<Response> {
|
||||||
|
const { signal, ...resizeOptions } = options;
|
||||||
|
|
||||||
|
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
|
||||||
|
// we need to use the Cloudflare Image Resize API directly.
|
||||||
|
if (!GITBOOK_IMAGE_RESIZE_URL) {
|
||||||
|
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
|
||||||
|
resizeOptions
|
||||||
|
)}/${encodeURIComponent(input)}`;
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
|
||||||
|
console.log(`resize image using cdn-cgi: ${resizeURL}`);
|
||||||
|
|
||||||
|
return copyImageResponse(
|
||||||
|
await fetch(resizeURL, {
|
||||||
|
headers: {
|
||||||
|
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||||
|
Accept:
|
||||||
|
resizeOptions.format === 'json'
|
||||||
|
? 'application/json'
|
||||||
|
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyOptions(options: CloudflareImageOptions): string {
|
||||||
|
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
|
||||||
|
return `${rest}${rest ? ',' : ''}${key}=${value}`;
|
||||||
|
}, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CloudflareImageOptions } from './types';
|
||||||
|
import { copyImageResponse } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an image by doing a request to the image itself using the Cloudflare fetch.
|
||||||
|
* https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||||
|
*
|
||||||
|
* This method doesn't work in Cloudflare Pages and is only supported in workers.
|
||||||
|
*/
|
||||||
|
export async function resizeImageWithCFFetch(
|
||||||
|
input: string,
|
||||||
|
options: CloudflareImageOptions & {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
): Promise<Response> {
|
||||||
|
const { signal, ...resizeOptions } = options;
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
|
||||||
|
console.log(`resize image using cf-fetch: ${input}`);
|
||||||
|
|
||||||
|
return copyImageResponse(
|
||||||
|
await fetch(input, {
|
||||||
|
headers: {
|
||||||
|
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||||
|
Accept:
|
||||||
|
resizeOptions.format === 'json'
|
||||||
|
? 'application/json'
|
||||||
|
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
cf: { image: resizeOptions },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './resizeImage';
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
|
||||||
|
import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
|
||||||
|
import { resizeImageWithCDNCgi } from './cdn-cgi';
|
||||||
|
import { resizeImageWithCFFetch } from './cf-fetch';
|
||||||
|
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of an image.
|
||||||
|
*/
|
||||||
|
export async function getImageSize(
|
||||||
|
input: string,
|
||||||
|
defaultSize: Partial<CloudflareImageOptions> = {}
|
||||||
|
): Promise<{ width: number; height: number } | null> {
|
||||||
|
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await resizeImage(input, {
|
||||||
|
// Abort the request after 2 seconds to avoid blocking rendering for too long
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
// Measure size and resize it to the most common size
|
||||||
|
// to optimize caching
|
||||||
|
...defaultSize,
|
||||||
|
format: 'json',
|
||||||
|
anim: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (await response.json()) as CloudflareImageJsonFormat;
|
||||||
|
return {
|
||||||
|
width: json.original.width,
|
||||||
|
height: json.original.height,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Error getting image size for ${input}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a Cloudflare Image Resize operation on an image.
|
||||||
|
*/
|
||||||
|
export async function resizeImage(
|
||||||
|
input: string,
|
||||||
|
options: CloudflareImageOptions & {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
): Promise<Response> {
|
||||||
|
const action = checkIsSizableImageURL(input);
|
||||||
|
if (action === SizableImageAction.Skip) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot resize this image, this function should have never been called on this url'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === SizableImageAction.Passthrough) {
|
||||||
|
return fetch(input, {
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (GITBOOK_IMAGE_RESIZE_MODE) {
|
||||||
|
case 'cdn-cgi':
|
||||||
|
return resizeImageWithCDNCgi(input, options);
|
||||||
|
case 'cf-fetch':
|
||||||
|
return resizeImageWithCFFetch(input, options);
|
||||||
|
default:
|
||||||
|
assertNever(GITBOOK_IMAGE_RESIZE_MODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface CloudflareImageJsonFormat {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
original: {
|
||||||
|
file_size: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
format: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
|
||||||
|
*/
|
||||||
|
export interface CloudflareImageOptions {
|
||||||
|
format?: 'webp' | 'avif' | 'json' | 'jpeg';
|
||||||
|
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
dpr?: number;
|
||||||
|
anim?: boolean;
|
||||||
|
quality?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Copy a response to make sure it can be mutated by the rest of the middleware.
|
||||||
|
* To avoid errors "Can't modify immutable headers".
|
||||||
|
*/
|
||||||
|
export function copyImageResponse(response: Response) {
|
||||||
|
return new Response(response.body, response);
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ export async function verifyImageSignature(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
|
||||||
const generated = await generator(input);
|
const generated = await generator(input);
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noConsole: we want to log the signature comparison
|
||||||
|
console.log(
|
||||||
|
`comparing image signature for "${input.url}" on identifier "${input.imagesContextId}": "${generated}" (expected) === "${signature}" (actual)`
|
||||||
|
);
|
||||||
return generated === signature;
|
return generated === signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +70,9 @@ const generateSignatureV2: SignFn = async (input) => {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(':');
|
.join(':');
|
||||||
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
|
||||||
|
const signature = fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
|
||||||
|
return signature;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reused buffer for FNV-1a hashing in the v1 algorithm
|
// Reused buffer for FNV-1a hashing in the v1 algorithm
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export function createLinker(
|
|||||||
},
|
},
|
||||||
|
|
||||||
toAbsoluteURL(absolutePath: string): string {
|
toAbsoluteURL(absolutePath: string): string {
|
||||||
|
// If the path is already a full URL, we return it as is.
|
||||||
|
if (URL.canParse(absolutePath)) {
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
if (!servedOn.host) {
|
if (!servedOn.host) {
|
||||||
return absolutePath;
|
return absolutePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,21 +10,22 @@ import {
|
|||||||
type ResponseCookies,
|
type ResponseCookies,
|
||||||
getPathScopedCookieName,
|
getPathScopedCookieName,
|
||||||
getResponseCookiesForVisitorAuth,
|
getResponseCookiesForVisitorAuth,
|
||||||
getVisitorToken,
|
getVisitorData,
|
||||||
normalizeVisitorAuthURL,
|
normalizeVisitorAuthURL,
|
||||||
} from '@/lib/visitor-token';
|
} from '@/lib/visitors';
|
||||||
import { serveResizedImage } from '@/routes/image';
|
import { serveResizedImage } from '@/routes/image';
|
||||||
import {
|
import {
|
||||||
DataFetcherError,
|
DataFetcherError,
|
||||||
getPublishedContentByURL,
|
getPublishedContentByURL,
|
||||||
|
getVisitorAuthBasePath,
|
||||||
normalizeURL,
|
normalizeURL,
|
||||||
|
resolvePublishedContentByUrl,
|
||||||
throwIfDataError,
|
throwIfDataError,
|
||||||
} from '@v2/lib/data';
|
} from '@v2/lib/data';
|
||||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
||||||
import { getImageResizingContextId } from '@v2/lib/images';
|
import { getImageResizingContextId } from '@v2/lib/images';
|
||||||
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
import { MiddlewareHeaders } from '@v2/lib/middleware';
|
||||||
import type { SiteURLData } from './lib/context';
|
import type { SiteURLData } from './lib/context';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
|
'/((?!_next/static|_next/image|~gitbook/static|~gitbook/revalidate|~gitbook/monitoring|~scalar/proxy).*)',
|
||||||
@@ -33,6 +34,15 @@ export const config = {
|
|||||||
|
|
||||||
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary list of hosts to test adaptive content using the new resolution API.
|
||||||
|
*/
|
||||||
|
const ADAPTIVE_CONTENT_HOSTS = [
|
||||||
|
'docs.gitbook.com',
|
||||||
|
'adaptive-docs.gitbook-staging.com',
|
||||||
|
'enriched-content-playground.gitbook-staging.io',
|
||||||
|
];
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const requestURL = new URL(request.url);
|
const requestURL = new URL(request.url);
|
||||||
@@ -85,17 +95,22 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
//
|
//
|
||||||
// Detect and extract the visitor authentication token from the request
|
// Detect and extract the visitor authentication token from the request
|
||||||
//
|
//
|
||||||
// @ts-ignore - request typing
|
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({
|
||||||
const visitorToken = getVisitorToken({
|
|
||||||
cookies: request.cookies.getAll(),
|
cookies: request.cookies.getAll(),
|
||||||
url: siteRequestURL,
|
url: siteRequestURL,
|
||||||
});
|
});
|
||||||
|
|
||||||
const withAPIToken = async (apiToken: string | null) => {
|
const withAPIToken = async (apiToken: string | null) => {
|
||||||
|
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
|
||||||
|
? resolvePublishedContentByUrl
|
||||||
|
: getPublishedContentByURL;
|
||||||
const siteURLData = await throwIfDataError(
|
const siteURLData = await throwIfDataError(
|
||||||
getPublishedContentByURL({
|
resolve({
|
||||||
url: siteRequestURL.toString(),
|
url: siteRequestURL.toString(),
|
||||||
visitorAuthToken: visitorToken?.token ?? null,
|
visitorPayload: {
|
||||||
|
jwtToken: visitorToken?.token ?? undefined,
|
||||||
|
unsignedClaims,
|
||||||
|
},
|
||||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||||
@@ -106,7 +121,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
apiToken,
|
apiToken,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const cookies: ResponseCookies = [];
|
|
||||||
|
const cookies: ResponseCookies = visitorParamsCookie
|
||||||
|
? [
|
||||||
|
// If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation.
|
||||||
|
visitorParamsCookie,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
//
|
//
|
||||||
// Handle redirects
|
// Handle redirects
|
||||||
@@ -137,24 +158,32 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
return NextResponse.redirect(siteURLData.redirect);
|
return NextResponse.redirect(siteURLData.redirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
cookies.push(...getResponseCookiesForVisitorAuth(siteURLData.siteBasePath, visitorToken));
|
cookies.push(
|
||||||
|
...getResponseCookiesForVisitorAuth(
|
||||||
|
getVisitorAuthBasePath(siteRequestURL, siteURLData),
|
||||||
|
visitorToken
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// We use the host/origin from the canonical URL to ensure the links are
|
// 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/...
|
// correctly generated when the site is proxied. e.g. https://proxy.gitbook.com/site/siteId/...
|
||||||
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
|
const siteCanonicalURL = new URL(siteURLData.canonicalUrl);
|
||||||
|
|
||||||
|
let incomingURL = requestURL;
|
||||||
|
// For cases where the site is proxied, we use the canonical URL
|
||||||
|
// as the incoming URL along with all the search params from the request.
|
||||||
|
if (mode !== 'url') {
|
||||||
|
incomingURL = siteCanonicalURL;
|
||||||
|
incomingURL.search = requestURL.search;
|
||||||
|
}
|
||||||
//
|
//
|
||||||
// Make sure the URL is clean of any va token after a successful lookup
|
// Make sure the URL is clean of any va token after a successful lookup
|
||||||
// The token is stored in a cookie that is set on the redirect response
|
// The token is stored in a cookie that is set on the redirect response
|
||||||
//
|
//
|
||||||
const incomingURL = mode === 'url' ? requestURL : siteCanonicalURL;
|
const incomingURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
||||||
const requestURLWithoutToken = normalizeVisitorAuthURL(incomingURL);
|
if (incomingURLWithoutToken.toString() !== incomingURL.toString()) {
|
||||||
if (
|
|
||||||
requestURLWithoutToken !== incomingURL &&
|
|
||||||
requestURLWithoutToken.toString() !== incomingURL.toString()
|
|
||||||
) {
|
|
||||||
return writeResponseCookies(
|
return writeResponseCookies(
|
||||||
NextResponse.redirect(requestURLWithoutToken.toString()),
|
NextResponse.redirect(incomingURLWithoutToken.toString()),
|
||||||
cookies
|
cookies
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,6 +196,23 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
// (customization override, theme, etc)
|
// (customization override, theme, etc)
|
||||||
let routeType: 'dynamic' | 'static' = 'static';
|
let routeType: 'dynamic' | 'static' = 'static';
|
||||||
|
|
||||||
|
// We pick only stable data from the siteURL data to prevent re-rendering of
|
||||||
|
// the root layout when changing pages..
|
||||||
|
const stableSiteURLData: SiteURLData = {
|
||||||
|
site: siteURLData.site,
|
||||||
|
siteSection: siteURLData.siteSection,
|
||||||
|
siteSpace: siteURLData.siteSpace,
|
||||||
|
siteBasePath: siteURLData.siteBasePath,
|
||||||
|
basePath: siteURLData.basePath,
|
||||||
|
space: siteURLData.space,
|
||||||
|
organization: siteURLData.organization,
|
||||||
|
changeRequest: siteURLData.changeRequest,
|
||||||
|
revision: siteURLData.revision,
|
||||||
|
shareKey: siteURLData.shareKey,
|
||||||
|
apiToken: siteURLData.apiToken,
|
||||||
|
imagesContextId: imagesContextId,
|
||||||
|
};
|
||||||
|
|
||||||
const requestHeaders = new Headers(request.headers);
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||||
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
|
||||||
@@ -174,7 +220,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
MiddlewareHeaders.SiteURL,
|
MiddlewareHeaders.SiteURL,
|
||||||
`${siteCanonicalURL.origin}${siteURLData.basePath}`
|
`${siteCanonicalURL.origin}${siteURLData.basePath}`
|
||||||
);
|
);
|
||||||
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(siteURLData));
|
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(stableSiteURLData));
|
||||||
|
|
||||||
// Preview of customization/theme
|
// Preview of customization/theme
|
||||||
const customization = siteRequestURL.searchParams.get('customization');
|
const customization = siteRequestURL.searchParams.get('customization');
|
||||||
@@ -204,23 +250,6 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
|||||||
);
|
);
|
||||||
routeType = routeTypeFromPathname ?? routeType;
|
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 = [
|
const route = [
|
||||||
'sites',
|
'sites',
|
||||||
routeType,
|
routeType,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"main": ".open-next/worker.js",
|
||||||
|
"name": "gitbook-open-v2",
|
||||||
|
"compatibility_date": "2025-04-14",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat",
|
||||||
|
"allow_importable_env",
|
||||||
|
"global_fetch_strictly_public"
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"directory": ".open-next/assets",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"preview": {
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "GITBOOK_API",
|
||||||
|
"service": "gitbook-x-prod-api-cache"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
// No durable objects on preview, as they block the generation of preview URLs
|
||||||
|
// and we don't need tags invalidation on preview
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"pattern": "open-2c.gitbook-staging.com/*",
|
||||||
|
"zone_name": "gitbook-staging.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "static-2c.gitbook-staging.com/*",
|
||||||
|
"zone_name": "gitbook-staging.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-staging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "GITBOOK_API",
|
||||||
|
"service": "gitbook-x-staging-api-cache"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-staging-tail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"vars": {
|
||||||
|
// This is a bit misleading, but it means that we can have 100 concurrent revalidations
|
||||||
|
// This means that we'll have up to 20 durable objects instance running at the same time
|
||||||
|
"MAX_REVALIDATE_CONCURRENCY": "20"
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"pattern": "open-2c.gitbook.com/*",
|
||||||
|
"zone_name": "gitbook.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "static-2c.gitbook.com/*",
|
||||||
|
"zone_name": "gitbook.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "GITBOOK_API",
|
||||||
|
"service": "gitbook-x-prod-api-cache"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-prod-tail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
main = ".open-next/worker.js"
|
|
||||||
name = "gitbook-open-v2"
|
|
||||||
compatibility_date = "2025-03-11"
|
|
||||||
compatibility_flags = ["nodejs_compat", "allow_importable_env"]
|
|
||||||
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
|
||||||
observability = { enabled = true }
|
|
||||||
|
|
||||||
[env.preview]
|
|
||||||
kv_namespaces = [
|
|
||||||
{ binding = "NEXT_CACHE_WORKERS_KV", id = "b7dd9cf58bf2458f84812a2d83b3760c" } # gitbook-open-v2-cache-preview
|
|
||||||
]
|
|
||||||
d1_databases = [
|
|
||||||
{ binding = "NEXT_CACHE_D1", database_id = "f59ddb40-ad72-4312-9395-0ac6a129af8e", database_name = "gitbook-open-v2-tags-preview" }
|
|
||||||
]
|
|
||||||
services = [
|
|
||||||
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-preview" },
|
|
||||||
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.staging]
|
|
||||||
routes = [
|
|
||||||
{ pattern = "open-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" },
|
|
||||||
{ pattern = "static-2c.gitbook-staging.com/*", zone_name = "gitbook-staging.com" }
|
|
||||||
]
|
|
||||||
kv_namespaces = [
|
|
||||||
{ binding = "NEXT_CACHE_WORKERS_KV", id = "a446e25f12b741afb185f1e5b4474f0a" } # gitbook-open-v2-cache-staging
|
|
||||||
]
|
|
||||||
d1_databases = [
|
|
||||||
{ binding = "NEXT_CACHE_D1", database_id = "9df62e39-1f35-4066-83aa-e9b8ed3ac8d5", database_name = "gitbook-open-v2-tags-staging" }
|
|
||||||
]
|
|
||||||
services = [
|
|
||||||
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-staging" },
|
|
||||||
{ binding = "GITBOOK_API", service = "gitbook-x-staging-api-cache" }
|
|
||||||
]
|
|
||||||
|
|
||||||
[env.production]
|
|
||||||
routes = [
|
|
||||||
{ pattern = "open-2c.gitbook.com/*", zone_name = "gitbook.com" },
|
|
||||||
{ pattern = "static-2c.gitbook.com/*", zone_name = "gitbook.com" }
|
|
||||||
]
|
|
||||||
kv_namespaces = [
|
|
||||||
{ binding = "NEXT_CACHE_WORKERS_KV", id = "72379746280d4e79acf24440eea950dc" } # gitbook-open-v2-cache-production
|
|
||||||
]
|
|
||||||
d1_databases = [
|
|
||||||
{ binding = "NEXT_CACHE_D1", database_id = "a6f16fce-5f45-43a9-89a4-7b83ddf25b77", database_name = "gitbook-open-v2-tags-production" }
|
|
||||||
]
|
|
||||||
services = [
|
|
||||||
{ binding = "NEXT_CACHE_REVALIDATION_WORKER", service = "gitbook-open-v2-production" },
|
|
||||||
{ binding = "GITBOOK_API", service = "gitbook-x-prod-api-cache" }
|
|
||||||
]
|
|
||||||
@@ -1,5 +1,143 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.12.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 8339e91: Fix images in reusable content across spaces.
|
||||||
|
- 326e28e: Design tweaks to code blocks and OpenAPI pages
|
||||||
|
- 3119066: Add support for reusable content across spaces.
|
||||||
|
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- c4ebb3f: Fix openapi-select hover in responses
|
||||||
|
- aed79fd: Decrease rounding of header logo
|
||||||
|
- 42ca7e1: Fix openapi CR preview
|
||||||
|
- e6ddc0f: Fix URL in sitemap
|
||||||
|
- 5e975ab: Fix code highlighting for HTTP
|
||||||
|
- 5d504ff: Fix resolution of links in reusable contents
|
||||||
|
- 95a1f65: Better print layouts: wrap code blocks & force table column auto-sizing
|
||||||
|
- 0499966: Fix invalid sitemap.xml generated with relative URLs instead of absolute ones
|
||||||
|
- 2a805cc: Change OpenAPI schema-optional from `info` to `tint` color
|
||||||
|
- 580101d: Fix schemas disclosure label causing client error
|
||||||
|
- 12a455d: Fix OpenAPI layout issues
|
||||||
|
- 97b7c79: Increase logging around caching behaviour causing page crashes.
|
||||||
|
- 373f18f: Prevent section group popovers from opening on click
|
||||||
|
- 3f29206: Update the regex for validating site redirect
|
||||||
|
- 0c973a3: Always link main logo to the root of the site
|
||||||
|
- ae5f1ab: Change `Dropdown`s to use Radix's `DropdownMenu`
|
||||||
|
- 0e201d5: Add border to filled sidebar on gradient theme
|
||||||
|
- dd043df: Revert investigation work around URL caches.
|
||||||
|
- 89a5816: Fix OpenAPI disclosure label ("Show properties") misalignment on mobile
|
||||||
|
- Updated dependencies [c3f6b8c]
|
||||||
|
- Updated dependencies [d00dc8c]
|
||||||
|
- Updated dependencies [42ca7e1]
|
||||||
|
- Updated dependencies [326e28e]
|
||||||
|
- Updated dependencies [5e975ab]
|
||||||
|
- Updated dependencies [f7a3470]
|
||||||
|
- Updated dependencies [580101d]
|
||||||
|
- Updated dependencies [20ebecb]
|
||||||
|
- Updated dependencies [80cb52a]
|
||||||
|
- Updated dependencies [cb5598d]
|
||||||
|
- Updated dependencies [c6637b0]
|
||||||
|
- Updated dependencies [a3ec264]
|
||||||
|
- @gitbook/colors@0.3.3
|
||||||
|
- @gitbook/openapi-parser@2.1.4
|
||||||
|
- @gitbook/react-openapi@1.3.0
|
||||||
|
|
||||||
|
## 0.11.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [ebc39e9]
|
||||||
|
- Updated dependencies [b6b09d4]
|
||||||
|
- @gitbook/react-openapi@1.2.1
|
||||||
|
|
||||||
|
## 0.11.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- d67699a: Add OpenAPI Webhook block
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 4b8a621: Show sections tabs only if there is at least two sections
|
||||||
|
- 8ed1bda: Translate OpenAPI blocks
|
||||||
|
- 7588cfe: Improve OpenAPIResponses examples and schemas
|
||||||
|
- Updated dependencies [eeb977f]
|
||||||
|
- Updated dependencies [3363a18]
|
||||||
|
- Updated dependencies [d67699a]
|
||||||
|
- Updated dependencies [8ed1bda]
|
||||||
|
- Updated dependencies [7588cfe]
|
||||||
|
- Updated dependencies [ad1dc0b]
|
||||||
|
- @gitbook/react-openapi@1.2.0
|
||||||
|
|
||||||
|
## 0.10.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [77397ca]
|
||||||
|
- @gitbook/cache-tags@0.3.1
|
||||||
|
|
||||||
|
## 0.10.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- b62b101: Do not set cookie to identify visitor for insights when disabled.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 95ea22d: Cache AI Page Link summary
|
||||||
|
- daf41fc: Tweak footer design (and refactor)
|
||||||
|
- de53946: Fix security issue with injection of "javacript:` url in the back button of PDFs
|
||||||
|
- b92ecfa: Implement retry logic for the DO cache to prevent when revalidating content.
|
||||||
|
- 528eee3: Add superscript and subscript text rendering
|
||||||
|
- aa3357a: Fix OpenAPISchemas description padding
|
||||||
|
- 168a4fa: Add support for buttons to GitBook.
|
||||||
|
- 70c4182: Improve OpenAPI schema style
|
||||||
|
- 2b6c593: Remove stable from x-stability
|
||||||
|
- 580f7ad: Improve the error message returned by the revalidate endpoint.
|
||||||
|
- cbd768a: Improve OpenAPI codesample (add OpenAPISelect component)
|
||||||
|
- c765463: Fix ogimage generation crashing when site is using a custom WOFF2 font
|
||||||
|
- e59076a: Improve OpenAPI schemas block ungrouped style. Classnames have changed, please refer to this PR to update GBX.
|
||||||
|
- 29aaba5: Override Scalar's overscroll-behavior
|
||||||
|
- 90ead98: Better error handling in cache revalidation.
|
||||||
|
- Updated dependencies [116575c]
|
||||||
|
- Updated dependencies [cdffd7c]
|
||||||
|
- Updated dependencies [70c4182]
|
||||||
|
- Updated dependencies [2b6c593]
|
||||||
|
- Updated dependencies [cbd768a]
|
||||||
|
- Updated dependencies [e59076a]
|
||||||
|
- Updated dependencies [eedefdd]
|
||||||
|
- Updated dependencies [23cedd2]
|
||||||
|
- @gitbook/cache-tags@0.3.0
|
||||||
|
- @gitbook/colors@0.3.2
|
||||||
|
- @gitbook/react-openapi@1.1.10
|
||||||
|
- @gitbook/openapi-parser@2.1.3
|
||||||
|
|
||||||
|
## 0.9.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- da7b369: Fix missing headers in OpenAPIResponses
|
||||||
|
- 139a805: Fix OpenAPI enum display
|
||||||
|
- Updated dependencies [da7b369]
|
||||||
|
- Updated dependencies [da485f5]
|
||||||
|
- Updated dependencies [139a805]
|
||||||
|
- @gitbook/react-openapi@1.1.9
|
||||||
|
|
||||||
|
## 0.9.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fb90eb0: Limit tinted background on bold theme to sites with filled sidebar
|
||||||
|
- 7d0b422: Handle grouped OpenAPISchemas
|
||||||
|
- Updated dependencies [7d0b422]
|
||||||
|
- Updated dependencies [fb90eb0]
|
||||||
|
- @gitbook/react-openapi@1.1.8
|
||||||
|
- @gitbook/colors@0.3.1
|
||||||
|
|
||||||
## 0.9.0
|
## 0.9.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
@@ -472,7 +610,7 @@
|
|||||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||||
- 3996110: Optimize images rendered in community ads
|
- 3996110: Optimize images rendered in community ads
|
||||||
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||||
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
- 5096f7f: Disable KV cache for gitbook.com/docs as a test, also disable it for change-request to improve consistency
|
||||||
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||||
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||||
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
|
|||||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: 'Nexthink',
|
// name: 'Nexthink',
|
||||||
contentBaseURL: 'https://docs.nexthink.com',
|
// contentBaseURL: 'https://docs.nexthink.com',
|
||||||
tests: [
|
// tests: [
|
||||||
{
|
// {
|
||||||
name: 'Home',
|
// name: 'Home',
|
||||||
url: '/',
|
// url: '/',
|
||||||
screenshot: { waitForTOCScrolling: false },
|
// screenshot: { waitForTOCScrolling: false },
|
||||||
run: waitForCookiesDialog,
|
// run: waitForCookiesDialog,
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||||
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||||
@@ -92,11 +92,6 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://book.character.ai',
|
contentBaseURL: 'https://book.character.ai',
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'docs.tradeonnova.io',
|
|
||||||
contentBaseURL: 'https://docs.tradeonnova.io',
|
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'azcoiner.gitbook.io',
|
name: 'azcoiner.gitbook.io',
|
||||||
contentBaseURL: 'https://azcoiner.gitbook.io',
|
contentBaseURL: 'https://azcoiner.gitbook.io',
|
||||||
@@ -162,11 +157,11 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://wiki.redmodding.org',
|
contentBaseURL: 'https://wiki.redmodding.org',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: 'docs.cherry-ai.com',
|
// name: 'docs.cherry-ai.com',
|
||||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
// contentBaseURL: 'https://docs.cherry-ai.com',
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: 'docs.snyk.io',
|
name: 'docs.snyk.io',
|
||||||
contentBaseURL: 'https://docs.snyk.io',
|
contentBaseURL: 'https://docs.snyk.io',
|
||||||
@@ -244,6 +239,15 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://docs.fluentbit.io',
|
contentBaseURL: 'https://docs.fluentbit.io',
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'run-ai-docs.nvidia.com',
|
||||||
|
contentBaseURL: 'https://run-ai-docs.nvidia.com',
|
||||||
|
skip: process.env.ARGOS_BUILD_NAME !== 'customers-v2',
|
||||||
|
tests: [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: 'OG Image', url: '/~gitbook/ogimage/h17zQIFwy3MaafVNmItO', mode: 'image' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
runTestCases(testCases);
|
runTestCases(testCases);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
VISITOR_TOKEN_COOKIE,
|
VISITOR_TOKEN_COOKIE,
|
||||||
getVisitorAuthCookieName,
|
getVisitorAuthCookieName,
|
||||||
getVisitorAuthCookieValue,
|
getVisitorAuthCookieValue,
|
||||||
} from '@/lib/visitor-token';
|
} from '@/lib/visitors';
|
||||||
|
|
||||||
import { getSiteAPIToken } from '../tests/utils';
|
import { getSiteAPIToken } from '../tests/utils';
|
||||||
import {
|
import {
|
||||||
@@ -111,24 +111,24 @@ const testCases: TestsCase[] = [
|
|||||||
name: 'Customized variant titles are displayed',
|
name: 'Customized variant titles are displayed',
|
||||||
url: '',
|
url: '',
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = page
|
const spaceDropdown = page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.locator('visible=true');
|
||||||
await spaceDrowpdown.click();
|
await spaceDropdown.click();
|
||||||
|
|
||||||
const variantSelectionDropdown = page.locator(
|
const variantSelectionDropdown = page.locator(
|
||||||
'css=[data-testid="space-dropdown-button"] + div'
|
'css=[data-testid="dropdown-menu"]'
|
||||||
);
|
);
|
||||||
// the customized space title
|
// the customized space title
|
||||||
await expect(
|
await expect(
|
||||||
variantSelectionDropdown.getByRole('link', {
|
variantSelectionDropdown.getByRole('menuitem', {
|
||||||
name: 'Multi-Variants',
|
name: 'Multi-Variants',
|
||||||
})
|
})
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// the NON-customized space title
|
// the NON-customized space title
|
||||||
await expect(
|
await expect(
|
||||||
variantSelectionDropdown.getByRole('link', {
|
variantSelectionDropdown.getByRole('menuitem', {
|
||||||
name: 'RFCs',
|
name: 'RFCs',
|
||||||
})
|
})
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -145,14 +145,17 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'api-multi-versions/reference/api-reference/pets',
|
url: 'api-multi-versions/reference/api-reference/pets',
|
||||||
screenshot: false,
|
screenshot: false,
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = await page
|
const spaceDropdown = await page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.locator('visible=true');
|
||||||
await spaceDrowpdown.click();
|
await spaceDropdown.click();
|
||||||
|
|
||||||
|
const variantSelectionDropdown = page.locator(
|
||||||
|
'css=[data-testid="dropdown-menu"]'
|
||||||
|
);
|
||||||
// Click the second variant in the dropdown
|
// Click the second variant in the dropdown
|
||||||
await page
|
await variantSelectionDropdown
|
||||||
.getByRole('link', {
|
.getByRole('menuitem', {
|
||||||
name: '2.0',
|
name: '2.0',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
@@ -168,14 +171,18 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||||
screenshot: false,
|
screenshot: false,
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = await page
|
const spaceDropdown = await page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.locator('visible=true');
|
||||||
await spaceDrowpdown.click();
|
await spaceDropdown.click();
|
||||||
|
|
||||||
|
const variantSelectionDropdown = page.locator(
|
||||||
|
'css=[data-testid="dropdown-menu"]'
|
||||||
|
);
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
// Click the second variant in the dropdown
|
||||||
await page
|
await variantSelectionDropdown
|
||||||
.getByRole('link', {
|
.getByRole('menuitem', {
|
||||||
name: '2.0',
|
name: '2.0',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
@@ -205,14 +212,18 @@ const testCases: TestsCase[] = [
|
|||||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||||
},
|
},
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const spaceDrowpdown = await page
|
const spaceDropdown = await page
|
||||||
.locator('[data-testid="space-dropdown-button"]')
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
.locator('visible=true');
|
.locator('visible=true');
|
||||||
await spaceDrowpdown.click();
|
await spaceDropdown.click();
|
||||||
|
|
||||||
|
const variantSelectionDropdown = page.locator(
|
||||||
|
'css=[data-testid="dropdown-menu"]'
|
||||||
|
);
|
||||||
|
|
||||||
// Click the second variant in the dropdown
|
// Click the second variant in the dropdown
|
||||||
await page
|
await variantSelectionDropdown
|
||||||
.getByRole('link', {
|
.getByRole('menuitem', {
|
||||||
name: '2.0',
|
name: '2.0',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
@@ -258,7 +269,7 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'GitBook',
|
name: 'GitBook',
|
||||||
contentBaseURL: 'https://docs.gitbook.com',
|
contentBaseURL: 'https://gitbook.com/docs/',
|
||||||
tests: [
|
tests: [
|
||||||
{
|
{
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
@@ -419,6 +430,43 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Site subdirectory (proxy)',
|
||||||
|
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||||
|
contentBaseURL: 'https://nextjs-gbo-proxy.vercel.app/documentation/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Main',
|
||||||
|
url: '',
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Site subdirectory (proxy) with VA',
|
||||||
|
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||||
|
contentBaseURL: 'https://nextjs-gbo-proxy-va.vercel.app/va/docs/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Main',
|
||||||
|
url: () => {
|
||||||
|
const privateKey =
|
||||||
|
'rqSfA6x7eAKx1qDRCDq9aCXwivpUvQ8YkXeDdFvCCUa9QchIcM7pF1iJ4o7AGOU49spmOWjKoIPtX0pVUVQ81w==';
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
name: 'gitbook-open-tests',
|
||||||
|
},
|
||||||
|
privateKey,
|
||||||
|
{
|
||||||
|
expiresIn: '24h',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return `?jwt_token=${token}`;
|
||||||
|
},
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Content tests',
|
name: 'Content tests',
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
@@ -837,6 +885,20 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Content Redirects',
|
||||||
|
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Redirect to new location',
|
||||||
|
url: '/content-editor/editing-content/inline/redirect-test',
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.locator('h1')).toHaveText('Redirect test');
|
||||||
|
},
|
||||||
|
screenshot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Site Redirects with sections',
|
name: 'Site Redirects with sections',
|
||||||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface Test {
|
|||||||
* Test to run
|
* Test to run
|
||||||
*/
|
*/
|
||||||
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
run?: (page: Page, response: Response | null) => Promise<unknown>;
|
||||||
|
/**
|
||||||
|
* Mode for the test.
|
||||||
|
*/
|
||||||
|
mode?: 'page' | 'image';
|
||||||
/**
|
/**
|
||||||
* Whether the test should be fullscreened during testing.
|
* Whether the test should be fullscreened during testing.
|
||||||
*/
|
*/
|
||||||
@@ -154,6 +158,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
|
|
||||||
test.describe(testCase.name, () => {
|
test.describe(testCase.name, () => {
|
||||||
for (const testEntry of testCase.tests) {
|
for (const testEntry of testCase.tests) {
|
||||||
|
const { mode = 'page' } = testEntry;
|
||||||
const testFn = testEntry.only ? test.only : test;
|
const testFn = testEntry.only ? test.only : test;
|
||||||
testFn(testEntry.name, async ({ page, context }) => {
|
testFn(testEntry.name, async ({ page, context }) => {
|
||||||
const testEntryPathname =
|
const testEntryPathname =
|
||||||
@@ -163,6 +168,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
||||||
)
|
)
|
||||||
: getTestURL(testEntryPathname);
|
: getTestURL(testEntryPathname);
|
||||||
|
|
||||||
if (testEntry.cookies) {
|
if (testEntry.cookies) {
|
||||||
await context.addCookies(
|
await context.addCookies(
|
||||||
testEntry.cookies.map((cookie) => ({
|
testEntry.cookies.map((cookie) => ({
|
||||||
@@ -194,24 +200,33 @@ export function runTestCases(testCases: TestsCase[]) {
|
|||||||
}
|
}
|
||||||
const screenshotOptions = testEntry.screenshot;
|
const screenshotOptions = testEntry.screenshot;
|
||||||
if (screenshotOptions !== false) {
|
if (screenshotOptions !== false) {
|
||||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
const screenshotName = `${testCase.name} - ${testEntry.name}`;
|
||||||
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
if (mode === 'image') {
|
||||||
argosCSS: `
|
await argosScreenshot(page, screenshotName, {
|
||||||
|
viewports: ['macbook-13'],
|
||||||
|
threshold: screenshotOptions?.threshold ?? undefined,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await argosScreenshot(page, screenshotName, {
|
||||||
|
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
|
||||||
|
argosCSS: `
|
||||||
/* Hide Intercom */
|
/* Hide Intercom */
|
||||||
.intercom-lightweight-app {
|
.intercom-lightweight-app {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
threshold: screenshotOptions?.threshold ?? undefined,
|
threshold: screenshotOptions?.threshold ?? undefined,
|
||||||
fullPage: testEntry.fullPage ?? false,
|
fullPage: testEntry.fullPage ?? false,
|
||||||
beforeScreenshot: async ({ runStabilization }) => {
|
beforeScreenshot: async ({ runStabilization }) => {
|
||||||
await runStabilization();
|
await runStabilization();
|
||||||
await waitForIcons(page);
|
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
await waitForTOCScrolling(page);
|
||||||
await waitForTOCScrolling(page);
|
}
|
||||||
}
|
await waitForIcons(page);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -276,6 +291,9 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
internationalization: {
|
internationalization: {
|
||||||
locale: CustomizationLocale.En,
|
locale: CustomizationLocale.En,
|
||||||
},
|
},
|
||||||
|
insights: {
|
||||||
|
trackingCookie: true,
|
||||||
|
},
|
||||||
favicon: {},
|
favicon: {},
|
||||||
header: {
|
header: {
|
||||||
preset: CustomizationHeaderPreset.Default,
|
preset: CustomizationHeaderPreset.Default,
|
||||||
@@ -328,61 +346,78 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
*/
|
*/
|
||||||
async function waitForIcons(page: Page) {
|
async function waitForIcons(page: Page) {
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const urls = new Set<string>();
|
const urlStates: Record<
|
||||||
|
string,
|
||||||
|
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||||
|
> = (window as any).__ICONS_STATES__ || {};
|
||||||
|
(window as any).__ICONS_STATES__ = urlStates;
|
||||||
|
|
||||||
|
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch SVG: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgText = await response.text();
|
||||||
|
const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22');
|
||||||
|
|
||||||
|
return `data:image/svg+xml;charset=utf-8,${encoded}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUrl = (url: string) => {
|
||||||
|
// Mark the URL as pending.
|
||||||
|
urlStates[url] = { state: 'pending', uri: null };
|
||||||
|
fetchSvgAsDataUri(url).then((uri) => {
|
||||||
|
urlStates[url] = { state: 'loaded', uri };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||||
const results = icons.map((icon) => {
|
const results = icons.map((icon) => {
|
||||||
if (!(icon instanceof SVGElement)) {
|
if (!(icon instanceof SVGElement)) {
|
||||||
throw new Error('Icon is not an SVGElement');
|
throw new Error('Icon is not an SVGElement');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If loaded, good it passes the test.
|
|
||||||
if (icon.dataset.loadingState === 'loaded') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not loaded yet, we need to load it.
|
|
||||||
if (icon.dataset.loadingState === 'pending') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore icons that are not visible.
|
// Ignore icons that are not visible.
|
||||||
if (!icon.checkVisibility()) {
|
if (!icon.checkVisibility()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = icon.getAttribute('data-argos-state');
|
||||||
|
|
||||||
|
if (state === 'pending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'loaded') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||||
const url = urlMatch ? urlMatch[1] : null;
|
const url = urlMatch?.[1];
|
||||||
|
|
||||||
// If URL is invalid we throw an error.
|
// If URL is invalid we throw an error.
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('No mask-image');
|
throw new Error('No mask-image');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the URL is already loaded, we just mark it as loaded.
|
// If the URL is already queued for loading, we return the state.
|
||||||
if (urls.has(url)) {
|
if (urlStates[url]) {
|
||||||
icon.dataset.loadingState = 'loaded';
|
if (urlStates[url].state === 'loaded') {
|
||||||
return true;
|
icon.setAttribute('data-argos-state', 'pending');
|
||||||
|
icon.style.maskImage = `url("${urlStates[url].uri}")`;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
icon.setAttribute('data-argos-state', 'loaded');
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the icon as pending and load the image.
|
loadUrl(url);
|
||||||
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 false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module.exports = {
|
|||||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
|
||||||
|
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||||
},
|
},
|
||||||
|
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.9.0",
|
"version": "0.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "*",
|
"@gitbook/api": "^0.115.0",
|
||||||
"@gitbook/cache-do": "workspace:*",
|
"@gitbook/cache-do": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
@@ -27,17 +27,18 @@
|
|||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@upstash/redis": "^1.27.1",
|
"ai": "^4.2.2",
|
||||||
"ai": "^4.1.46",
|
|
||||||
"ajv": "^8.12.0",
|
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "^1.2.1",
|
||||||
"bun-types": "^1.1.20",
|
"bun-types": "^1.1.20",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"event-iterator": "^2.0.0",
|
||||||
"framer-motion": "^10.16.14",
|
"framer-motion": "^10.16.14",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsontoxml": "^1.0.1",
|
"jsontoxml": "^1.0.1",
|
||||||
@@ -46,15 +47,16 @@
|
|||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"mdast-util-to-markdown": "^2.1.2",
|
"mdast-util-to-markdown": "^2.1.2",
|
||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
"next": "14.2.25",
|
"next": "14.2.26",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.0",
|
"p-map": "^7.0.0",
|
||||||
"parse-cache-control": "^1.0.1",
|
"parse-cache-control": "^1.0.1",
|
||||||
"react": "18.3.1",
|
"partial-json": "^0.1.7",
|
||||||
"react-dom": "18.3.1",
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -68,11 +70,14 @@
|
|||||||
"tailwind-shades": "^1.1.2",
|
"tailwind-shades": "^1.1.2",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"usehooks-ts": "^3.1.0"
|
"usehooks-ts": "^3.1.0",
|
||||||
|
"zod": "^3.24.2",
|
||||||
|
"zod-to-json-schema": "^3.24.5",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^4.3.0",
|
"@argos-ci/playwright": "^5.0.3",
|
||||||
"@cloudflare/next-on-pages": "1.13.7",
|
"@cloudflare/next-on-pages": "1.13.12",
|
||||||
"@cloudflare/workers-types": "^4.20241230.0",
|
"@cloudflare/workers-types": "^4.20241230.0",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.51.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
# GitBook immutable static assets
|
# GitBook immutable static assets
|
||||||
|
# Duplicated from next.config.mjs until OpenNext supports generating static headers
|
||||||
/~gitbook/static/*
|
/~gitbook/static/*
|
||||||
cache-control: public,max-age=31536000,immutable
|
cache-control: public,max-age=31536000,immutable
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
/_next/static/*
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ interface JsonBody {
|
|||||||
* The body should be a JSON with { tags: string[] }
|
* The body should be a JSON with { tags: string[] }
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const json = (await req.json()) as JsonBody;
|
let json: JsonBody;
|
||||||
|
|
||||||
|
try {
|
||||||
|
json = await req.json();
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `invalid json body: ${err}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!json.tags || !Array.isArray(json.tags)) {
|
if (!json.tags || !Array.isArray(json.tags)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -25,10 +33,18 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await revalidateTags(json.tags);
|
try {
|
||||||
|
const result = await revalidateTags(json.tags);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
stats: result.stats,
|
stats: result.stats,
|
||||||
});
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `${err}`,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import { useScrollPage } from '@/components/hooks';
|
|||||||
export function PageClientLayout(props: { withSections?: boolean }) {
|
export function PageClientLayout(props: { withSections?: boolean }) {
|
||||||
// We use this hook in the page layout to ensure the elements for the blocks
|
// We use this hook in the page layout to ensure the elements for the blocks
|
||||||
// are rendered before we scroll to a hash or to the top of the page
|
// are rendered before we scroll to a hash or to the top of the page
|
||||||
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
|
useScrollPage({ scrollMarginTop: props.withSections ? 48 : undefined });
|
||||||
|
|
||||||
useStripFallbackQueryParam();
|
useStripFallbackQueryParam();
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
export default function ErrorPage(props: {
|
export default function ErrorPage(props: {
|
||||||
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
|
|||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
label={tString(language, 'unexpected_error_retry')}
|
||||||
{t(language, 'unexpected_error_retry')}
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { t } from '@/intl/translate';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useVisitedPages } from '../Insights';
|
||||||
|
import { usePageContext } from '../PageContext';
|
||||||
|
import { Loading } from '../primitives';
|
||||||
|
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a unique cache key for a page summary
|
||||||
|
*/
|
||||||
|
function getCacheKey(targetSpaceId: string, targetPageId: string): string {
|
||||||
|
return `${targetSpaceId}:${targetPageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global state for the summaries.
|
||||||
|
*/
|
||||||
|
const useSummaries = create<{
|
||||||
|
/**
|
||||||
|
* Cache of all summaries generated so far.
|
||||||
|
*/
|
||||||
|
cache: Map<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary for a page.
|
||||||
|
*/
|
||||||
|
getSummary: (params: { targetSpaceId: string; targetPageId: string }) => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the generation of a summary for a page.
|
||||||
|
*/
|
||||||
|
streamSummary: (params: {
|
||||||
|
currentSpaceId: string;
|
||||||
|
currentPageId: string;
|
||||||
|
currentPageTitle: string;
|
||||||
|
targetSpaceId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
linkPreview?: string;
|
||||||
|
linkTitle?: string;
|
||||||
|
visitedPages: { spaceId: string; pageId: string }[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
}>((set, get) => ({
|
||||||
|
cache: new Map(),
|
||||||
|
|
||||||
|
getSummary: ({
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
}: {
|
||||||
|
targetSpaceId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
}) => {
|
||||||
|
return get().cache.get(getCacheKey(targetSpaceId, targetPageId)) ?? '';
|
||||||
|
},
|
||||||
|
|
||||||
|
streamSummary: async ({
|
||||||
|
currentSpaceId,
|
||||||
|
currentPageId,
|
||||||
|
currentPageTitle,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
}) => {
|
||||||
|
const cacheKey = getCacheKey(targetSpaceId, targetPageId);
|
||||||
|
|
||||||
|
if (get().cache.has(cacheKey)) {
|
||||||
|
// Already generated or generating
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = (summary: string) => {
|
||||||
|
set((prev) => {
|
||||||
|
const newCache = new Map(prev.cache);
|
||||||
|
newCache.set(cacheKey, summary);
|
||||||
|
return { cache: newCache };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
update('');
|
||||||
|
const stream = await streamLinkPageSummary({
|
||||||
|
currentSpaceId,
|
||||||
|
currentPageId,
|
||||||
|
currentPageTitle,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
});
|
||||||
|
|
||||||
|
let generatedSummary = '';
|
||||||
|
for await (const highlight of stream) {
|
||||||
|
generatedSummary = highlight ?? '';
|
||||||
|
update(generatedSummary);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarise a page's content for use in a link preview
|
||||||
|
*/
|
||||||
|
export function AIPageLinkSummary(props: {
|
||||||
|
targetSpaceId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
linkPreview?: string;
|
||||||
|
linkTitle?: string;
|
||||||
|
showTrademark: boolean;
|
||||||
|
}) {
|
||||||
|
const { targetSpaceId, targetPageId, linkPreview, linkTitle, showTrademark = true } = props;
|
||||||
|
|
||||||
|
const currentPage = usePageContext();
|
||||||
|
const language = useLanguage();
|
||||||
|
const visitedPages = useVisitedPages((state) => state.pages);
|
||||||
|
const { summary, streamSummary } = useSummaries(
|
||||||
|
useShallow((state) => {
|
||||||
|
return {
|
||||||
|
summary: state.getSummary({ targetSpaceId, targetPageId }),
|
||||||
|
streamSummary: state.streamSummary,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
streamSummary({
|
||||||
|
currentSpaceId: currentPage.spaceId,
|
||||||
|
currentPageId: currentPage.pageId,
|
||||||
|
currentPageTitle: currentPage.title,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
currentPage.pageId,
|
||||||
|
currentPage.spaceId,
|
||||||
|
currentPage.title,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
streamSummary,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shimmerBlocks = [
|
||||||
|
'w-[20%] [animation-delay:-1s]',
|
||||||
|
'w-[35%] [animation-delay:-0.8s]',
|
||||||
|
'w-[25%] [animation-delay:-0.6s]',
|
||||||
|
'w-[10%] [animation-delay:-0.4s]',
|
||||||
|
'w-[40%] [animation-delay:-0.2s]',
|
||||||
|
'w-[30%] [animation-delay:0s]',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||||
|
{showTrademark ? (
|
||||||
|
<Loading className="size-4" busy={!summary || summary.length === 0} />
|
||||||
|
) : (
|
||||||
|
<Icon icon="sparkle" className="size-3" />
|
||||||
|
)}
|
||||||
|
<h6 className="text-tint">{t(language, 'link_tooltip_ai_summary')}</h6>
|
||||||
|
</div>
|
||||||
|
{summary.length > 0 ? (
|
||||||
|
<p className="animate-fadeIn">{summary}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{shimmerBlocks.map((block, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${block}`}
|
||||||
|
className={`${block} h-4 animate-pulse rounded straight-corners:rounded-none bg-tint-active`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary.length > 0 ? (
|
||||||
|
<div className="animate-fadeIn text-tint-subtle text-xs">
|
||||||
|
{t(language, 'link_tooltip_ai_summary_description')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './AIPageLinkSummary';
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use server';
|
||||||
|
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
|
||||||
|
import type { GitBookBaseContext } from '@v2/lib/context';
|
||||||
|
import { EventIterator } from 'event-iterator';
|
||||||
|
import type { MaybePromise } from 'p-map';
|
||||||
|
import * as partialJson from 'partial-json';
|
||||||
|
import type { DeepPartial } from 'ts-essentials';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest value from a stream and the response id.
|
||||||
|
*/
|
||||||
|
export async function generate<T>(
|
||||||
|
promise: MaybePromise<{
|
||||||
|
stream: EventIterator<T>;
|
||||||
|
response: Promise<{ responseId: string }>;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const input = await promise;
|
||||||
|
let value: T | undefined;
|
||||||
|
|
||||||
|
for await (const event of input.stream) {
|
||||||
|
value = event;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { responseId } = await input.response;
|
||||||
|
return {
|
||||||
|
responseId,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the generation of an object using the AI.
|
||||||
|
*/
|
||||||
|
export async function streamGenerateObject<T>(
|
||||||
|
context: GitBookBaseContext,
|
||||||
|
{
|
||||||
|
organizationId,
|
||||||
|
siteId,
|
||||||
|
}: {
|
||||||
|
organizationId: string;
|
||||||
|
siteId: string;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema,
|
||||||
|
messages,
|
||||||
|
model = AIModel.Fast,
|
||||||
|
}: {
|
||||||
|
schema: z.ZodSchema<T>;
|
||||||
|
messages: AIMessageInput[];
|
||||||
|
model?: AIModel;
|
||||||
|
previousResponseId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const rawStream = context.dataFetcher.streamAIResponse({
|
||||||
|
organizationId,
|
||||||
|
siteId,
|
||||||
|
input: messages,
|
||||||
|
output: {
|
||||||
|
type: 'object',
|
||||||
|
schema: zodToJsonSchema(schema),
|
||||||
|
},
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
|
||||||
|
let json = '';
|
||||||
|
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
|
||||||
|
if (event.type === 'response_object') {
|
||||||
|
json += event.jsonChunk;
|
||||||
|
|
||||||
|
const parsed = partialJson.parse(json, partialJson.ALL);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a stream from the API to extract the responseId.
|
||||||
|
*/
|
||||||
|
function parseResponse<T>(
|
||||||
|
responseStream: EventIterator<AIStreamResponse>,
|
||||||
|
parse: (response: AIStreamResponse) => T | undefined
|
||||||
|
): {
|
||||||
|
stream: EventIterator<T>;
|
||||||
|
response: Promise<{ responseId: string }>;
|
||||||
|
} {
|
||||||
|
let resolveResponse: (value: { responseId: string }) => void;
|
||||||
|
const response = new Promise<{ responseId: string }>((resolve) => {
|
||||||
|
resolveResponse = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = new EventIterator<T>((queue) => {
|
||||||
|
(async () => {
|
||||||
|
let foundResponse = false;
|
||||||
|
|
||||||
|
for await (const event of responseStream) {
|
||||||
|
if (event.type === 'response_finish') {
|
||||||
|
foundResponse = true;
|
||||||
|
resolveResponse({ responseId: event.responseId });
|
||||||
|
} else {
|
||||||
|
const parsed = parse(event);
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
queue.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundResponse) {
|
||||||
|
throw new Error('No response found');
|
||||||
|
}
|
||||||
|
})().then(
|
||||||
|
() => {
|
||||||
|
queue.stop();
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
queue.fail(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stream, response };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './streamLinkPageSummary';
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use server';
|
||||||
|
import { filterOutNullable } from '@/lib/typescript';
|
||||||
|
import { getV1BaseContext } from '@/lib/v1';
|
||||||
|
import { isV2 } from '@/lib/v2';
|
||||||
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
|
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||||
|
import { getServerActionBaseContext } from '@v2/lib/server-actions';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { streamGenerateObject } from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary of a page, in the context of another page
|
||||||
|
*/
|
||||||
|
export async function* streamLinkPageSummary({
|
||||||
|
currentSpaceId,
|
||||||
|
currentPageId,
|
||||||
|
targetSpaceId,
|
||||||
|
targetPageId,
|
||||||
|
linkPreview,
|
||||||
|
linkTitle,
|
||||||
|
visitedPages,
|
||||||
|
}: {
|
||||||
|
currentSpaceId: string;
|
||||||
|
currentPageId: string;
|
||||||
|
currentPageTitle: string;
|
||||||
|
targetSpaceId: string;
|
||||||
|
targetPageId: string;
|
||||||
|
linkPreview?: string;
|
||||||
|
linkTitle?: string;
|
||||||
|
visitedPages?: Array<{ spaceId: string; pageId: string }>;
|
||||||
|
}) {
|
||||||
|
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
|
||||||
|
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||||
|
|
||||||
|
const { stream } = await streamGenerateObject(
|
||||||
|
baseContext,
|
||||||
|
{
|
||||||
|
organizationId: siteURLData.organization,
|
||||||
|
siteId: siteURLData.site,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schema: z.object({
|
||||||
|
highlight: z
|
||||||
|
.string()
|
||||||
|
.describe('The reason why the user should read the target page.'),
|
||||||
|
// questions: z.array(z.string().describe('The questions to sea')).max(3),
|
||||||
|
}),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 1. Role
|
||||||
|
You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph.
|
||||||
|
|
||||||
|
# 2. Task
|
||||||
|
Extract a contextually-relevant fact that:
|
||||||
|
- Directly answers the specific need or question implied by the link's placement
|
||||||
|
- States a capability, limitation, or specification from the target page
|
||||||
|
- Connects precisely to the user's current paragraph or sentence
|
||||||
|
- Completes the user's understanding based on what they're currently reading
|
||||||
|
|
||||||
|
# 3. Instructions
|
||||||
|
1. First, identify the exact need, question, or gap in the current paragraph where the link appears
|
||||||
|
2. Find the specific fact in the target page that addresses this exact contextual need
|
||||||
|
3. Ensure the fact relates directly to the context of the paragraph containing the link
|
||||||
|
4. Avoid ALL instructional language including words like "use", "click", "select", "create"
|
||||||
|
5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 4. Current page
|
||||||
|
The content of the current page is:`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
spaceId: currentSpaceId,
|
||||||
|
pageId: currentPageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...(visitedPages
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: '# 5. Previous pages',
|
||||||
|
},
|
||||||
|
...visitedPages.map(({ spaceId, pageId }) => ({
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `## Page ${pageId}`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 6. Target page
|
||||||
|
The content of the target page is:`,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'page' as const,
|
||||||
|
spaceId: targetSpaceId,
|
||||||
|
pageId: targetPageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 7. Link preview
|
||||||
|
The content of the link preview is:
|
||||||
|
> ${linkPreview}
|
||||||
|
> Page ID: ${targetPageId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.Developer,
|
||||||
|
content: `# 8. Guidelines & Examples
|
||||||
|
ALWAYS:
|
||||||
|
- ALWAYS choose facts that directly fulfill the contextual need where the link appears
|
||||||
|
- ALWAYS connect target page information specifically to the current paragraph context
|
||||||
|
- ALWAYS focus on the gap in knowledge that the link is meant to fill
|
||||||
|
- ALWAYS consider user's navigation history to ensure contextual continuity
|
||||||
|
- ALWAYS use action verbs like "click", "select", "use", "create", "enable"
|
||||||
|
|
||||||
|
NEVER:
|
||||||
|
- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly.
|
||||||
|
- NEVER select general facts unrelated to the specific link context
|
||||||
|
- NEVER ignore the specific context where the link appears
|
||||||
|
- NEVER repeat the same fact in different words
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once."
|
||||||
|
Preview: "Multiple Select: Select multiple content blocks at once."
|
||||||
|
✓ "Shift selects content between two points, useful for reorganizing your current heading structure."
|
||||||
|
✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks."
|
||||||
|
|
||||||
|
Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]."
|
||||||
|
Preview: "Change Requests: Collaborative content editing workflow."
|
||||||
|
✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions."
|
||||||
|
✗ "Each reviewer receives an email notification and can approve or request changes."
|
||||||
|
|
||||||
|
Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]."
|
||||||
|
Preview: "Live Edit: Real-time collaborative editing."
|
||||||
|
✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations."
|
||||||
|
✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: AIMessageRole.User,
|
||||||
|
content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`,
|
||||||
|
},
|
||||||
|
].filter(filterOutNullable),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const value of stream) {
|
||||||
|
const highlight = value.highlight;
|
||||||
|
if (!highlight) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,9 +97,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateState(true);
|
onUpdateState(true);
|
||||||
}}
|
}}
|
||||||
>
|
label={tString(language, 'cookies_accept')}
|
||||||
{t(language, 'cookies_accept')}
|
/>
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -107,9 +106,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateState(false);
|
onUpdateState(false);
|
||||||
}}
|
}}
|
||||||
>
|
label={tString(language, 'cookies_reject')}
|
||||||
{t(language, 'cookies_reject')}
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { IntegrationBlock } from './Integration';
|
|||||||
import { List } from './List';
|
import { List } from './List';
|
||||||
import { ListItem } from './ListItem';
|
import { ListItem } from './ListItem';
|
||||||
import { BlockMath } from './Math';
|
import { BlockMath } from './Math';
|
||||||
import { OpenAPIOperation, OpenAPISchemas } from './OpenAPI';
|
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
|
||||||
import { Paragraph } from './Paragraph';
|
import { Paragraph } from './Paragraph';
|
||||||
import { Quote } from './Quote';
|
import { Quote } from './Quote';
|
||||||
import { ReusableContent } from './ReusableContent';
|
import { ReusableContent } from './ReusableContent';
|
||||||
@@ -85,6 +85,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
|||||||
return <OpenAPIOperation {...props} block={block} />;
|
return <OpenAPIOperation {...props} block={block} />;
|
||||||
case 'openapi-schemas':
|
case 'openapi-schemas':
|
||||||
return <OpenAPISchemas {...props} block={block} />;
|
return <OpenAPISchemas {...props} block={block} />;
|
||||||
|
case 'openapi-webhook':
|
||||||
|
return <OpenAPIWebhook {...props} block={block} />;
|
||||||
case 'embed':
|
case 'embed':
|
||||||
return <Embed {...props} block={block} />;
|
return <Embed {...props} block={block} />;
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
@@ -159,6 +161,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
|
|||||||
case 'swagger':
|
case 'swagger':
|
||||||
case 'openapi-operation':
|
case 'openapi-operation':
|
||||||
case 'openapi-schemas':
|
case 'openapi-schemas':
|
||||||
|
case 'openapi-webhook':
|
||||||
case 'math':
|
case 'math':
|
||||||
case 'divider':
|
case 'divider':
|
||||||
case 'content-ref':
|
case 'content-ref':
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
const [isInViewport, setIsInViewport] = useState(false);
|
const [isInViewport, setIsInViewport] = useState(false);
|
||||||
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
const plainLines = useMemo(() => plainHighlight(block, []), [block]);
|
||||||
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
const [lines, setLines] = useState<null | HighlightLine[]>(null);
|
||||||
|
const [highlighting, setHighlighting] = useState(false);
|
||||||
|
|
||||||
// Preload the highlighter when the block is mounted.
|
// Preload the highlighter when the block is mounted.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,6 +78,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
setHighlighting(true);
|
||||||
import('./highlight').then(({ highlight }) => {
|
import('./highlight').then(({ highlight }) => {
|
||||||
highlight(block, inlines).then((lines) => {
|
highlight(block, inlines).then((lines) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -84,6 +86,7 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLines(lines);
|
setLines(lines);
|
||||||
|
setHighlighting(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,6 +101,12 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
|||||||
}, [isInViewport, block, inlines]);
|
}, [isInViewport, block, inlines]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines ?? plainLines} />
|
<CodeBlockRenderer
|
||||||
|
ref={blockRef}
|
||||||
|
aria-busy={highlighting}
|
||||||
|
block={block}
|
||||||
|
style={style}
|
||||||
|
lines={lines ?? plainLines}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.highlight-line-number {
|
.highlight-line-number {
|
||||||
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
|
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint-subtle contrast-more:from-tint-base theme-muted:from-tint-base [html.theme-bold.sidebar-filled_&]:from-tint-base to-transparent;
|
||||||
@apply before:text-tint before:content-[counter(line)];
|
@apply before:text-tint before:content-[counter(line)];
|
||||||
|
|
||||||
.highlight-line.highlighted > & {
|
.highlight-line.highlighted > & {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import './CodeBlockRenderer.css';
|
|||||||
|
|
||||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||||
lines: HighlightLine[];
|
lines: HighlightLine[];
|
||||||
|
'aria-busy'?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
props: CodeBlockRendererProps,
|
props: CodeBlockRendererProps,
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
) {
|
) {
|
||||||
const { block, style, lines } = props;
|
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
||||||
|
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||||
@@ -31,10 +32,14 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
const title = block.data.title;
|
const title = block.data.title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
|
<div
|
||||||
|
ref={ref}
|
||||||
|
aria-busy={ariaBusy}
|
||||||
|
className={tcls('group/codeblock grid grid-flow-col', style)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
<div className="flex items-center justify-start gap-2 text-sm [grid-area:1/1]">
|
||||||
{title ? (
|
{title ? (
|
||||||
<div className="inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s bg-tint px-3 py-2 text-tint text-xs leading-none tracking-wide">
|
<div className="relative top-px z-20 inline-flex items-center justify-center rounded-t straight-corners:rounded-t-s border border-tint-subtle border-b-0 bg-tint-subtle theme-muted:bg-tint-base px-3 py-2 text-tint text-xs leading-none tracking-wide contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -45,15 +50,15 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
|||||||
/>
|
/>
|
||||||
<pre
|
<pre
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'hide-scroll relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle [grid-area:2/1]',
|
'hide-scroll relative overflow-auto border border-tint-subtle bg-tint-subtle theme-muted:bg-tint-base [grid-area:2/1] contrast-more:border-tint contrast-more:bg-tint-base [html.theme-bold.sidebar-filled_&]:bg-tint-base',
|
||||||
'rounded-md straight-corners:rounded-sm',
|
'rounded-md straight-corners:rounded-sm shadow-sm',
|
||||||
title && 'rounded-ss-none'
|
title && 'rounded-ss-none'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<code
|
<code
|
||||||
id={id}
|
id={id}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line]',
|
'inline-grid min-w-full grid-cols-[auto_1fr] p-2 [count-reset:line] print:whitespace-pre-wrap',
|
||||||
withWrap && 'whitespace-pre-wrap'
|
withWrap && 'whitespace-pre-wrap'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,31 +1,67 @@
|
|||||||
:root {
|
:root {
|
||||||
--shiki-color-text: theme("colors.tint.11");
|
--shiki-color-text: theme("colors.tint.11");
|
||||||
--shiki-token-constant: #0a6355;
|
--shiki-token-punctuation: theme("colors.tint.11");
|
||||||
--shiki-token-string: #8b6d32;
|
--shiki-token-comment: theme("colors.neutral.9/.7");
|
||||||
--shiki-token-comment: theme("colors.teal.700/.64");
|
--shiki-token-link: theme("colors.primary.10");
|
||||||
--shiki-token-keyword: theme("colors.pomegranate.600");
|
|
||||||
--shiki-token-parameter: #0a3069;
|
--shiki-token-constant: theme("colors.warning.10");
|
||||||
--shiki-token-function: #8250df;
|
--shiki-token-string: theme("colors.warning.10");
|
||||||
--shiki-token-string-expression: #6a4906;
|
--shiki-token-string-expression: theme("colors.success.10");
|
||||||
--shiki-token-punctuation: theme("colors.pomegranate.700/.92");
|
--shiki-token-keyword: theme("colors.danger.10");
|
||||||
--shiki-token-link: theme("colors.tint.12");
|
--shiki-token-parameter: theme("colors.warning.10");
|
||||||
--shiki-token-inserted: #22863a;
|
--shiki-token-function: theme("colors.primary.10");
|
||||||
--shiki-token-deleted: #b31d28;
|
|
||||||
--shiki-token-changed: #8250df;
|
--shiki-token-inserted: theme("colors.success.10");
|
||||||
|
--shiki-token-deleted: theme("colors.danger.10");
|
||||||
|
--shiki-token-changed: theme("colors.tint.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
:root {
|
||||||
|
--shiki-color-text: theme("colors.tint.12");
|
||||||
|
--shiki-token-punctuation: theme("colors.tint.12");
|
||||||
|
--shiki-token-comment: theme("colors.neutral.11");
|
||||||
|
--shiki-token-link: theme("colors.primary.11");
|
||||||
|
|
||||||
|
--shiki-token-constant: theme("colors.warning.11");
|
||||||
|
--shiki-token-string: theme("colors.warning.11");
|
||||||
|
--shiki-token-string-expression: theme("colors.success.11");
|
||||||
|
--shiki-token-keyword: theme("colors.danger.11");
|
||||||
|
--shiki-token-parameter: theme("colors.warning.11");
|
||||||
|
--shiki-token-function: theme("colors.primary.11");
|
||||||
|
|
||||||
|
--shiki-token-inserted: theme("colors.success.11");
|
||||||
|
--shiki-token-deleted: theme("colors.danger.11");
|
||||||
|
--shiki-token-changed: theme("colors.tint.12");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
--shiki-color-text: theme("colors.tint.11");
|
/* Override select colors to have more contrast */
|
||||||
--shiki-token-constant: #d19a66;
|
--shiki-token-comment: theme("colors.neutral.9");
|
||||||
--shiki-token-string: theme("colors.pomegranate.300");
|
|
||||||
--shiki-token-comment: theme("colors.teal.300/.64");
|
--shiki-token-constant: theme("colors.warning.11");
|
||||||
--shiki-token-keyword: theme("colors.pomegranate.400");
|
--shiki-token-string: theme("colors.warning.11");
|
||||||
--shiki-token-parameter: theme("colors.yellow.500");
|
--shiki-token-string-expression: theme("colors.success.11");
|
||||||
--shiki-token-function: #56b6c2;
|
--shiki-token-keyword: theme("colors.danger.11");
|
||||||
--shiki-token-string-expression: theme("colors.tint.11");
|
--shiki-token-parameter: theme("colors.warning.11");
|
||||||
--shiki-token-punctuation: #acc6ee;
|
--shiki-token-function: theme("colors.primary.11");
|
||||||
--shiki-token-link: theme("colors.pomegranate.400");
|
}
|
||||||
--shiki-token-inserted: #85e89d;
|
|
||||||
--shiki-token-deleted: #fdaeb7;
|
.code-monochrome {
|
||||||
--shiki-token-changed: #56b6c2;
|
--shiki-token-constant: theme("colors.tint.11");
|
||||||
|
--shiki-token-string: theme("colors.tint.12");
|
||||||
|
--shiki-token-string-expression: theme("colors.tint.12");
|
||||||
|
--shiki-token-keyword: theme("colors.primary.10");
|
||||||
|
--shiki-token-parameter: theme("colors.tint.9");
|
||||||
|
--shiki-token-function: theme("colors.primary.9");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark.code-monochrome {
|
||||||
|
--shiki-token-constant: theme("colors.tint.11");
|
||||||
|
--shiki-token-string: theme("colors.tint.12");
|
||||||
|
--shiki-token-string-expression: theme("colors.tint.12");
|
||||||
|
--shiki-token-keyword: theme("colors.primary.11");
|
||||||
|
--shiki-token-parameter: theme("colors.tint.10");
|
||||||
|
--shiki-token-function: theme("colors.primary.10");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card } from '@/components/primitives';
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { getDataOrNull } from '@v2/lib/data';
|
import { getDataOrNull } from '@v2/lib/data';
|
||||||
|
import { Image } from '../utils';
|
||||||
import type { BlockProps } from './Block';
|
import type { BlockProps } from './Block';
|
||||||
import { Caption } from './Caption';
|
import { Caption } from './Caption';
|
||||||
import { IntegrationBlock } from './Integration';
|
import { IntegrationBlock } from './Integration';
|
||||||
@@ -52,7 +53,14 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
|||||||
<Card
|
<Card
|
||||||
leadingIcon={
|
leadingIcon={
|
||||||
embed.icon ? (
|
embed.icon ? (
|
||||||
<img src={embed.icon} className={tcls('w-5', 'h-5')} alt="Logo" />
|
<Image
|
||||||
|
src={embed.icon}
|
||||||
|
className={tcls('w-5', 'h-5')}
|
||||||
|
alt="Logo"
|
||||||
|
sources={{ light: { src: embed.icon } }}
|
||||||
|
sizes={[{ width: 20 }]}
|
||||||
|
resize={context.contentContext.imageResizer}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
href={block.data.url}
|
href={block.data.url}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
import type { DocumentBlockHeading, DocumentBlockTabs } from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { getBlockTextStyle } from './spacing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hash icon which adds the block or active block item's ID in the URL hash.
|
||||||
|
* The button needs to be wrapped in a container with `hashLinkButtonWrapperStyles`.
|
||||||
|
*/
|
||||||
|
export const hashLinkButtonWrapperStyles = tcls('relative', 'group/hash');
|
||||||
|
|
||||||
|
export function HashLinkButton(props: {
|
||||||
|
id: string;
|
||||||
|
block: DocumentBlockTabs | DocumentBlockHeading;
|
||||||
|
label?: string;
|
||||||
|
className?: ClassValue;
|
||||||
|
iconClassName?: ClassValue;
|
||||||
|
}) {
|
||||||
|
const { id, block, className, iconClassName, label = 'Direct link to block' } = props;
|
||||||
|
const textStyle = getBlockTextStyle(block);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'relative',
|
||||||
|
'hash',
|
||||||
|
'grid',
|
||||||
|
'grid-area-1-1',
|
||||||
|
'h-[1em]',
|
||||||
|
'border-0',
|
||||||
|
'opacity-0',
|
||||||
|
'group-hover/hash:opacity-[0]',
|
||||||
|
'group-focus/hash:opacity-[0]',
|
||||||
|
'md:group-hover/hash:md:opacity-[1]',
|
||||||
|
'md:group-focus/hash:md:opacity-[1]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`#${id}`}
|
||||||
|
aria-label={label}
|
||||||
|
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="hashtag"
|
||||||
|
className={tcls(
|
||||||
|
'size-3',
|
||||||
|
'self-center',
|
||||||
|
'transition-colors',
|
||||||
|
'text-transparent',
|
||||||
|
'group-hover/hash:text-tint-subtle',
|
||||||
|
'contrast-more:group-hover/hash:text-tint-strong',
|
||||||
|
iconClassName
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { DocumentBlockHeading } from '@gitbook/api';
|
import type { DocumentBlockHeading } from '@gitbook/api';
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import type { BlockProps } from './Block';
|
import type { BlockProps } from './Block';
|
||||||
|
import { HashLinkButton, hashLinkButtonWrapperStyles } from './HashLinkButton';
|
||||||
import { Inlines } from './Inlines';
|
import { Inlines } from './Inlines';
|
||||||
import { getBlockTextStyle } from './spacing';
|
import { getBlockTextStyle } from './spacing';
|
||||||
|
|
||||||
@@ -20,45 +20,23 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
|
|||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
id={id}
|
id={id}
|
||||||
className={tcls(textStyle.textSize, 'heading', 'group', 'relative', 'grid', style)}
|
className={tcls(
|
||||||
|
textStyle.textSize,
|
||||||
|
'heading',
|
||||||
|
'grid',
|
||||||
|
'scroll-m-12',
|
||||||
|
hashLinkButtonWrapperStyles,
|
||||||
|
style
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<HashLinkButton
|
||||||
className={tcls(
|
id={id}
|
||||||
'hash',
|
block={block}
|
||||||
'grid',
|
className={tcls('-ml-6', textStyle.anchorButtonMarginTop)}
|
||||||
'grid-area-1-1',
|
iconClassName={tcls('size-4')}
|
||||||
'relative',
|
label="Direct link to heading"
|
||||||
'-ml-6',
|
/>
|
||||||
'w-7',
|
|
||||||
'border-0',
|
|
||||||
'opacity-0',
|
|
||||||
'group-hover:opacity-[0]',
|
|
||||||
'group-focus:opacity-[0]',
|
|
||||||
'md:group-hover:md:opacity-[1]',
|
|
||||||
'md:group-focus:md:opacity-[1]',
|
|
||||||
textStyle.marginTop
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`#${id}`}
|
|
||||||
aria-label="Direct link to heading"
|
|
||||||
className={tcls('inline-flex', 'h-full', 'items-start', textStyle.lineHeight)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="hashtag"
|
|
||||||
className={tcls(
|
|
||||||
'w-3.5',
|
|
||||||
'h-[1em]',
|
|
||||||
'mt-0.5',
|
|
||||||
'transition-colors',
|
|
||||||
'text-transparent',
|
|
||||||
'group-hover:text-tint-subtle',
|
|
||||||
'contrast-more:group-hover:text-tint-strong',
|
|
||||||
'lg:w-4'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'grid-area-1-1',
|
'grid-area-1-1',
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const HINT_STYLES: {
|
|||||||
'[&_.can-override-text]:text-neutral-strong',
|
'[&_.can-override-text]:text-neutral-strong',
|
||||||
],
|
],
|
||||||
container:
|
container:
|
||||||
'bg-info border-info theme-muted-tint:bg-info-solid/2 theme-bold-tint:bg-info-solid/2',
|
'bg-info border-info theme-muted-tint:bg-info-solid/2 [html.sidebar-filled.theme-bold.tint_&]:bg-info-solid/2',
|
||||||
containerWithHeader: 'border-info-solid bg-info-subtle',
|
containerWithHeader: 'border-info-solid bg-info-subtle',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
DocumentInline,
|
DocumentInline,
|
||||||
DocumentInlineAnnotation,
|
DocumentInlineAnnotation,
|
||||||
|
DocumentInlineButton,
|
||||||
DocumentInlineEmoji,
|
DocumentInlineEmoji,
|
||||||
DocumentInlineImage,
|
DocumentInlineImage,
|
||||||
DocumentInlineLink,
|
DocumentInlineLink,
|
||||||
@@ -13,6 +14,7 @@ import assertNever from 'assert-never';
|
|||||||
import { Annotation } from './Annotation/Annotation';
|
import { Annotation } from './Annotation/Annotation';
|
||||||
import type { DocumentContextProps } from './DocumentView';
|
import type { DocumentContextProps } from './DocumentView';
|
||||||
import { Emoji } from './Emoji';
|
import { Emoji } from './Emoji';
|
||||||
|
import { InlineButton } from './InlineButton';
|
||||||
import { InlineImage } from './InlineImage';
|
import { InlineImage } from './InlineImage';
|
||||||
import { InlineLink } from './InlineLink';
|
import { InlineLink } from './InlineLink';
|
||||||
import { InlineMath } from './Math';
|
import { InlineMath } from './Math';
|
||||||
@@ -44,7 +46,8 @@ export function Inline<
|
|||||||
| DocumentInlineEmoji
|
| DocumentInlineEmoji
|
||||||
| DocumentInlineLink
|
| DocumentInlineLink
|
||||||
| DocumentInlineMath
|
| DocumentInlineMath
|
||||||
| DocumentInlineMention,
|
| DocumentInlineMention
|
||||||
|
| DocumentInlineButton,
|
||||||
>(props: InlineProps<T>) {
|
>(props: InlineProps<T>) {
|
||||||
const { inline, ...contextProps } = props;
|
const { inline, ...contextProps } = props;
|
||||||
|
|
||||||
@@ -61,6 +64,8 @@ export function Inline<
|
|||||||
return <Mention {...contextProps} inline={inline} />;
|
return <Mention {...contextProps} inline={inline} />;
|
||||||
case 'inline-image':
|
case 'inline-image':
|
||||||
return <InlineImage {...contextProps} inline={inline} />;
|
return <InlineImage {...contextProps} inline={inline} />;
|
||||||
|
case 'button':
|
||||||
|
return <InlineButton {...contextProps} inline={inline} />;
|
||||||
default:
|
default:
|
||||||
assertNever(inline);
|
assertNever(inline);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { resolveContentRef } from '@/lib/references';
|
||||||
|
import * as api from '@gitbook/api';
|
||||||
|
import { Button } from '../primitives';
|
||||||
|
import type { InlineProps } from './Inline';
|
||||||
|
|
||||||
|
export async function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||||
|
const { inline, context } = props;
|
||||||
|
|
||||||
|
if (!context.contentContext) {
|
||||||
|
throw new Error('InlineButton requires a contentContext');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveContentRef(inline.data.ref, context.contentContext);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
href={resolved.href}
|
||||||
|
label={inline.data.label}
|
||||||
|
// TODO: use a variant specifically for user-defined buttons.
|
||||||
|
variant={inline.data.kind}
|
||||||
|
insights={{
|
||||||
|
type: 'link_click',
|
||||||
|
link: {
|
||||||
|
target: inline.data.ref,
|
||||||
|
position: api.SiteInsightsLinkPosition.Content,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
|
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
|
||||||
|
|
||||||
import { resolveContentRef } from '@/lib/references';
|
import { resolveContentRef } from '@/lib/references';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
import { StyledLink } from '../primitives';
|
import { StyledLink } from '../primitives';
|
||||||
import type { InlineProps } from './Inline';
|
import type { InlineProps } from './Inline';
|
||||||
|
import { InlineLinkTooltip } from './InlineLinkTooltip';
|
||||||
import { Inlines } from './Inlines';
|
import { Inlines } from './Inlines';
|
||||||
|
|
||||||
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||||
const { inline, document, context, ancestorInlines } = props;
|
const { inline, document, context, ancestorInlines } = props;
|
||||||
|
|
||||||
const resolved = context.contentContext
|
const resolved = context.contentContext
|
||||||
? await resolveContentRef(inline.data.ref, context.contentContext)
|
? await resolveContentRef(inline.data.ref, context.contentContext, {
|
||||||
|
resolveAnchorText: true,
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!resolved) {
|
if (!context.contentContext || !resolved) {
|
||||||
return (
|
return (
|
||||||
<span title="Broken link" className="underline">
|
<span title="Broken link" className="underline">
|
||||||
<Inlines
|
<Inlines
|
||||||
@@ -25,24 +28,33 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const isExternal = inline.data.ref.kind === 'url';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledLink
|
<InlineLinkTooltip inline={inline} context={context.contentContext} resolved={resolved}>
|
||||||
href={resolved.href}
|
<StyledLink
|
||||||
insights={{
|
href={resolved.href}
|
||||||
type: 'link_click',
|
insights={{
|
||||||
link: {
|
type: 'link_click',
|
||||||
target: inline.data.ref,
|
link: {
|
||||||
position: SiteInsightsLinkPosition.Content,
|
target: inline.data.ref,
|
||||||
},
|
position: SiteInsightsLinkPosition.Content,
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
<Inlines
|
>
|
||||||
context={context}
|
<Inlines
|
||||||
document={document}
|
context={context}
|
||||||
nodes={inline.nodes}
|
document={document}
|
||||||
ancestorInlines={[...ancestorInlines, inline]}
|
nodes={inline.nodes}
|
||||||
/>
|
ancestorInlines={[...ancestorInlines, inline]}
|
||||||
</StyledLink>
|
/>
|
||||||
|
{isExternal ? (
|
||||||
|
<Icon
|
||||||
|
icon="arrow-up-right"
|
||||||
|
className="ml-0.5 inline size-3 links-accent:text-tint-subtle"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</StyledLink>
|
||||||
|
</InlineLinkTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { DocumentInlineLink } from '@gitbook/api';
|
||||||
|
|
||||||
|
import type { ResolvedContentRef } from '@/lib/references';
|
||||||
|
|
||||||
|
import { getSpaceLanguage } from '@/intl/server';
|
||||||
|
import { tString } from '@/intl/translate';
|
||||||
|
import { languages } from '@/intl/translations';
|
||||||
|
import { getNodeText } from '@/lib/document';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||||
|
import type { GitBookAnyContext } from '@v2/lib/context';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { AIPageLinkSummary } from '../Adaptive/AIPageLinkSummary';
|
||||||
|
import { Button, StyledLink } from '../primitives';
|
||||||
|
|
||||||
|
export async function InlineLinkTooltip(props: {
|
||||||
|
inline: DocumentInlineLink;
|
||||||
|
context: GitBookAnyContext;
|
||||||
|
children: React.ReactNode;
|
||||||
|
resolved: ResolvedContentRef;
|
||||||
|
}) {
|
||||||
|
const { inline, context, resolved, children } = props;
|
||||||
|
|
||||||
|
let breadcrumbs = resolved.ancestors;
|
||||||
|
const language =
|
||||||
|
'customization' in context ? getSpaceLanguage(context.customization) : languages.en;
|
||||||
|
const isExternal = inline.data.ref.kind === 'url';
|
||||||
|
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
|
||||||
|
if (isExternal) {
|
||||||
|
breadcrumbs = [
|
||||||
|
{
|
||||||
|
label: tString(language, 'link_tooltip_external_link'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isSamePage) {
|
||||||
|
breadcrumbs = [
|
||||||
|
{
|
||||||
|
label: tString(language, 'link_tooltip_page_anchor'),
|
||||||
|
icon: <Icon icon="arrow-down-short-wide" className="size-3" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
resolved.subText = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAISummary =
|
||||||
|
!isExternal &&
|
||||||
|
!isSamePage &&
|
||||||
|
'customization' in context &&
|
||||||
|
context.customization.ai?.pageLinkSummaries.enabled &&
|
||||||
|
(inline.data.ref.kind === 'page' || inline.data.ref.kind === 'anchor');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip.Provider delayDuration={200}>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
|
||||||
|
<div className="overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
|
||||||
|
<div className="bg-tint-base p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{breadcrumbs && breadcrumbs.length > 0 ? (
|
||||||
|
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
|
||||||
|
{breadcrumbs.map((crumb, index) => {
|
||||||
|
const Tag = crumb.href ? StyledLink : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={crumb.label}>
|
||||||
|
{index !== 0 ? (
|
||||||
|
<Icon
|
||||||
|
icon="chevron-right"
|
||||||
|
className="size-3 text-tint-subtle"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Tag
|
||||||
|
className={tcls(
|
||||||
|
'flex gap-1',
|
||||||
|
crumb.href &&
|
||||||
|
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
|
||||||
|
)}
|
||||||
|
href={crumb.href ?? '#'}
|
||||||
|
>
|
||||||
|
{crumb.icon ? (
|
||||||
|
<span className="mt-0.5 text-tint-subtle empty:hidden">
|
||||||
|
{crumb.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{crumb.label}
|
||||||
|
</Tag>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex gap-2 leading-snug',
|
||||||
|
isExternal && 'text-sm [overflow-wrap:anywhere]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resolved.icon ? (
|
||||||
|
<div className="mt-1 text-tint-subtle empty:hidden">
|
||||||
|
{resolved.icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<h5 className="font-semibold">{resolved.text}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isSamePage && resolved.href ? (
|
||||||
|
<Button
|
||||||
|
className={tcls(
|
||||||
|
'-mx-2 -my-2 ml-auto',
|
||||||
|
breadcrumbs?.length === 0
|
||||||
|
? 'place-self-center'
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
variant="blank"
|
||||||
|
href={resolved.href}
|
||||||
|
target="_blank"
|
||||||
|
label={tString(language, 'open_in_new_tab')}
|
||||||
|
size="small"
|
||||||
|
icon="arrow-up-right-from-square"
|
||||||
|
iconOnly={true}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{resolved.subText ? (
|
||||||
|
<p className="mt-1 text-sm text-tint">{resolved.subText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAISummary && 'page' in context && 'page' in inline.data.ref ? (
|
||||||
|
<div className="border-tint-subtle border-t bg-tint p-4">
|
||||||
|
<AIPageLinkSummary
|
||||||
|
targetPageId={
|
||||||
|
resolved.page?.id ??
|
||||||
|
inline.data.ref.page ??
|
||||||
|
context.page.id
|
||||||
|
}
|
||||||
|
targetSpaceId={inline.data.ref.space ?? context.space.id}
|
||||||
|
linkTitle={getNodeText(inline)}
|
||||||
|
linkPreview={`**${resolved.text}**: ${resolved.subText}`}
|
||||||
|
showTrademark={
|
||||||
|
'customization' in context &&
|
||||||
|
context.customization.trademark.enabled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Tooltip.Arrow className={hasAISummary ? 'fill-tint-3' : 'fill-tint-1'} />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ export const contentKitServerContext: ContentKitServerContext = {
|
|||||||
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
|
'link-external': (props) => <Icon icon="arrow-up-right-from-square" {...props} />,
|
||||||
eye: (props) => <Icon icon="eye" {...props} />,
|
eye: (props) => <Icon icon="eye" {...props} />,
|
||||||
lock: (props) => <Icon icon="lock" {...props} />,
|
lock: (props) => <Icon icon="lock" {...props} />,
|
||||||
|
check: (props) => <Icon icon="check" {...props} />,
|
||||||
|
'check-circle': (props) => <Icon icon="check-circle" {...props} />,
|
||||||
},
|
},
|
||||||
codeBlock: (props) => {
|
codeBlock: (props) => {
|
||||||
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
|
return <PlainCodeBlock code={props.code} syntax={props.syntax} />;
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import type { JSONDocument } from '@gitbook/api';
|
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { OpenAPIOperation as BaseOpenAPIOperation } from '@gitbook/react-openapi';
|
import { OpenAPIOperation as BaseOpenAPIOperation } from '@gitbook/react-openapi';
|
||||||
|
|
||||||
import { resolveOpenAPIOperationBlock } from '@/lib/openapi/resolveOpenAPIOperationBlock';
|
import { resolveOpenAPIOperationBlock } from '@/lib/openapi/resolveOpenAPIOperationBlock';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { PlainCodeBlock } from '../CodeBlock';
|
|
||||||
import { DocumentView } from '../DocumentView';
|
|
||||||
import { Heading } from '../Heading';
|
|
||||||
|
|
||||||
import './scalar.css';
|
import './scalar.css';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
||||||
|
import { getOpenAPIContext } from './context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an openapi block or an openapi-operation block.
|
* Render an openapi block or an openapi-operation block.
|
||||||
@@ -55,56 +51,7 @@ async function OpenAPIOperationBody(props: BlockProps<AnyOpenAPIOperationsBlock>
|
|||||||
return (
|
return (
|
||||||
<BaseOpenAPIOperation
|
<BaseOpenAPIOperation
|
||||||
data={data}
|
data={data}
|
||||||
context={{
|
context={getOpenAPIContext({ props, specUrl, context: context.contentContext })}
|
||||||
specUrl,
|
|
||||||
icons: {
|
|
||||||
chevronDown: <Icon icon="chevron-down" />,
|
|
||||||
chevronRight: <Icon icon="chevron-right" />,
|
|
||||||
plus: <Icon icon="plus" />,
|
|
||||||
},
|
|
||||||
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
|
|
||||||
renderDocument: (documentProps) => (
|
|
||||||
<DocumentView
|
|
||||||
document={documentProps.document as JSONDocument}
|
|
||||||
context={props.context}
|
|
||||||
style="space-y-6"
|
|
||||||
blockStyle="max-w-full"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
renderHeading: (headingProps) => (
|
|
||||||
<Heading
|
|
||||||
document={props.document}
|
|
||||||
ancestorBlocks={props.ancestorBlocks}
|
|
||||||
isEstimatedOffscreen={props.isEstimatedOffscreen}
|
|
||||||
context={props.context}
|
|
||||||
style={tcls([
|
|
||||||
headingProps.deprecated ? 'line-through' : undefined,
|
|
||||||
headingProps.deprecated || !!headingProps.stability
|
|
||||||
? '[&>div]:mt-0'
|
|
||||||
: undefined,
|
|
||||||
])}
|
|
||||||
block={{
|
|
||||||
object: 'block',
|
|
||||||
key: `${block.key}-heading`,
|
|
||||||
meta: block.meta,
|
|
||||||
data: {},
|
|
||||||
type: 'heading-2',
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
key: `${block.key}-heading-text`,
|
|
||||||
object: 'text',
|
|
||||||
leaves: [
|
|
||||||
{ text: headingProps.title, object: 'leaf', marks: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
defaultInteractiveOpened: context.mode === 'print',
|
|
||||||
id: block.meta?.id,
|
|
||||||
blockKey: block.key,
|
|
||||||
}}
|
|
||||||
className="openapi-block"
|
className="openapi-block"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { resolveOpenAPISchemasBlock } from '@/lib/openapi/resolveOpenAPISchemasBlock';
|
import { resolveOpenAPISchemasBlock } from '@/lib/openapi/resolveOpenAPISchemasBlock';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
|
import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
|
||||||
|
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
@@ -8,6 +7,7 @@ import type { BlockProps } from '../Block';
|
|||||||
import './scalar.css';
|
import './scalar.css';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
||||||
|
import { getOpenAPIContext } from './context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an openapi-schemas block.
|
* Render an openapi-schemas block.
|
||||||
@@ -49,18 +49,9 @@ async function OpenAPISchemasBody(props: BlockProps<OpenAPISchemasBlock>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseOpenAPISchemas
|
<BaseOpenAPISchemas
|
||||||
data={data}
|
schemas={data.schemas}
|
||||||
context={{
|
grouped={block.data.grouped}
|
||||||
specUrl,
|
context={getOpenAPIContext({ props, specUrl, context: context.contentContext })}
|
||||||
icons: {
|
|
||||||
chevronDown: <Icon icon="chevron-down" />,
|
|
||||||
chevronRight: <Icon icon="chevron-right" />,
|
|
||||||
plus: <Icon icon="plus" />,
|
|
||||||
},
|
|
||||||
defaultInteractiveOpened: context.mode === 'print',
|
|
||||||
id: block.meta?.id,
|
|
||||||
blockKey: block.key,
|
|
||||||
}}
|
|
||||||
className="openapi-block"
|
className="openapi-block"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { OpenAPIWebhook as BaseOpenAPIWebhook } from '@gitbook/react-openapi';
|
||||||
|
|
||||||
|
import { resolveOpenAPIWebhookBlock } from '@/lib/openapi/resolveOpenAPIWebhookBlock';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import type { BlockProps } from '../Block';
|
||||||
|
|
||||||
|
import './scalar.css';
|
||||||
|
import './style.css';
|
||||||
|
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
|
||||||
|
import { getOpenAPIContext } from './context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an openapi block or an openapi-webhook block.
|
||||||
|
*/
|
||||||
|
export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||||
|
const { style } = props;
|
||||||
|
return (
|
||||||
|
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||||
|
<OpenAPIWebhookBody {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function OpenAPIWebhookBody(props: BlockProps<OpenAPIWebhookBlock>) {
|
||||||
|
const { block, context } = props;
|
||||||
|
|
||||||
|
if (!context.contentContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, specUrl, error } = await resolveOpenAPIWebhookBlock({
|
||||||
|
block,
|
||||||
|
context: context.contentContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="hidden">
|
||||||
|
<p>
|
||||||
|
Error with {specUrl}: {error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !specUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseOpenAPIWebhook
|
||||||
|
data={data}
|
||||||
|
context={getOpenAPIContext({ props, specUrl, context: context.contentContext })}
|
||||||
|
className="openapi-block"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { JSONDocument } from '@gitbook/api';
|
||||||
|
import { Icon } from '@gitbook/icons';
|
||||||
|
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||||
|
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import type { BlockProps } from '../Block';
|
||||||
|
import { PlainCodeBlock } from '../CodeBlock';
|
||||||
|
import { DocumentView } from '../DocumentView';
|
||||||
|
import { Heading } from '../Heading';
|
||||||
|
|
||||||
|
import './scalar.css';
|
||||||
|
import './style.css';
|
||||||
|
import { DEFAULT_LOCALE, getCustomizationLocale } from '@/intl/server';
|
||||||
|
import type {
|
||||||
|
AnyOpenAPIOperationsBlock,
|
||||||
|
OpenAPISchemasBlock,
|
||||||
|
OpenAPIWebhookBlock,
|
||||||
|
} from '@/lib/openapi/types';
|
||||||
|
import type { GitBookAnyContext } from '@v2/lib/context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OpenAPI context to render a block.
|
||||||
|
*/
|
||||||
|
export function getOpenAPIContext(args: {
|
||||||
|
props: BlockProps<AnyOpenAPIOperationsBlock | OpenAPISchemasBlock | OpenAPIWebhookBlock>;
|
||||||
|
specUrl: string;
|
||||||
|
context: GitBookAnyContext | undefined;
|
||||||
|
}): OpenAPIContextInput {
|
||||||
|
const { props, specUrl, context } = args;
|
||||||
|
const { block } = props;
|
||||||
|
|
||||||
|
const customization = context && 'customization' in context ? context.customization : null;
|
||||||
|
const customizationLocale = customization
|
||||||
|
? getCustomizationLocale(customization)
|
||||||
|
: DEFAULT_LOCALE;
|
||||||
|
const locale = checkIsValidLocale(customizationLocale) ? customizationLocale : DEFAULT_LOCALE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
specUrl,
|
||||||
|
icons: {
|
||||||
|
chevronDown: <Icon icon="chevron-down" />,
|
||||||
|
chevronRight: <Icon icon="chevron-right" />,
|
||||||
|
plus: <Icon icon="plus" />,
|
||||||
|
},
|
||||||
|
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
|
||||||
|
renderDocument: (documentProps) => (
|
||||||
|
<DocumentView
|
||||||
|
document={documentProps.document as JSONDocument}
|
||||||
|
context={props.context}
|
||||||
|
style="space-y-6"
|
||||||
|
blockStyle="max-w-full"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
renderHeading: (headingProps) => (
|
||||||
|
<Heading
|
||||||
|
document={props.document}
|
||||||
|
ancestorBlocks={props.ancestorBlocks}
|
||||||
|
isEstimatedOffscreen={props.isEstimatedOffscreen}
|
||||||
|
context={props.context}
|
||||||
|
style={tcls([
|
||||||
|
headingProps.deprecated ? 'line-through' : undefined,
|
||||||
|
headingProps.deprecated || !!headingProps.stability
|
||||||
|
? '[&>div]:mt-0'
|
||||||
|
: undefined,
|
||||||
|
])}
|
||||||
|
block={{
|
||||||
|
object: 'block',
|
||||||
|
key: `${block.key}-heading`,
|
||||||
|
meta: block.meta,
|
||||||
|
data: {},
|
||||||
|
type: 'heading-2',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
key: `${block.key}-heading-text`,
|
||||||
|
object: 'text',
|
||||||
|
leaves: [{ text: headingProps.title, object: 'leaf', marks: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
defaultInteractiveOpened: props.context.mode === 'print',
|
||||||
|
id: block.meta?.id,
|
||||||
|
blockKey: block.key,
|
||||||
|
locale,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './OpenAPIOperation';
|
export * from './OpenAPIOperation';
|
||||||
export * from './OpenAPISchemas';
|
export * from './OpenAPISchemas';
|
||||||
|
export * from './OpenAPIWebhook';
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
@import "@scalar/api-client-react/style.css";
|
@import "@scalar/api-client-react/style.css";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
/** Override Scalar's overscroll-behavior */
|
||||||
|
@apply !overscroll-auto;
|
||||||
|
}
|
||||||
|
|
||||||
.light .scalar-modal-layout,
|
.light .scalar-modal-layout,
|
||||||
.light .scalar-app,
|
.light .scalar-app,
|
||||||
.light .scalar {
|
.light .scalar {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/* Layout Components */
|
/* Layout Components */
|
||||||
.openapi-operation {
|
.openapi-operation,
|
||||||
|
.openapi-schemas,
|
||||||
|
.openapi-webhook {
|
||||||
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
|
@apply flex-1 flex flex-col gap-8 mb-14 min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,6 +9,10 @@
|
|||||||
@apply flex flex-col mb-14 flex-1;
|
@apply flex flex-col mb-14 flex-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-title {
|
||||||
|
@apply tabular-nums text-[0.813rem] leading-4 font-mono shrink-0 font-medium text-tint-strong;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-columns {
|
.openapi-columns {
|
||||||
@apply grid grid-cols-1 lg:grid-cols-2 gap-6 print-mode:grid-cols-1 justify-stretch;
|
@apply grid grid-cols-1 lg:grid-cols-2 gap-6 print-mode:grid-cols-1 justify-stretch;
|
||||||
}
|
}
|
||||||
@@ -17,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-summary {
|
.openapi-summary {
|
||||||
@apply flex flex-col items-start justify-start gap-3;
|
@apply flex flex-col items-start justify-start gap-3 scroll-m-12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-summary-tags {
|
.openapi-summary-tags {
|
||||||
@@ -29,10 +35,6 @@
|
|||||||
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
|
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-stability-stable {
|
|
||||||
@apply text-green-600 dark:text-green-300 bg-green-50 dark:bg-green-900/6 ring-green-500/5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-stability-alpha {
|
.openapi-stability-alpha {
|
||||||
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
|
@apply text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/6 ring-amber-500/5;
|
||||||
}
|
}
|
||||||
@@ -49,10 +51,7 @@
|
|||||||
@apply font-semibold font-mono truncate;
|
@apply font-semibold font-mono truncate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-description.openapi-markdown {
|
.openapi-description.openapi-markdown,
|
||||||
@apply prose-sm text-[0.938rem];
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-description.openapi-markdown code {
|
.openapi-description.openapi-markdown code {
|
||||||
@apply prose-sm;
|
@apply prose-sm;
|
||||||
}
|
}
|
||||||
@@ -92,21 +91,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Method Tags */
|
/* Method Tags */
|
||||||
.openapi-method {
|
.openapi-method,
|
||||||
@apply rounded uppercase font-mono font-bold text-xs px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
|
.openapi-statuscode {
|
||||||
|
@apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-method-get {
|
.openapi-method-get,
|
||||||
/* @apply bg-[hsl(215,54%,86%)] dark:bg-[hsla(215,54%,45%,0.24)] dark:text-[hsl(215,54%,86%)]; */
|
.openapi-statuscode-success {
|
||||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-method-post {
|
.openapi-method-post,
|
||||||
/* @apply bg-[hsl(120,25%,80%)] dark:bg-[hsla(120,54%,32%,0.24)] dark:text-[hsl(120,25%,80%)]; */
|
.openapi-statuscode-redirection {
|
||||||
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-method-put {
|
.openapi-method-put,
|
||||||
|
.openapi-statuscode-informational {
|
||||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +115,9 @@
|
|||||||
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-method-delete {
|
.openapi-method-delete,
|
||||||
@apply bg-pomegranate-100 text-pomegranate-800 dark:bg-pomegranate-900 dark:text-pomegranate-100;
|
.openapi-statuscode-error {
|
||||||
|
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-method-head,
|
.openapi-method-head,
|
||||||
@@ -143,7 +145,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-column-preview-body {
|
.openapi-column-preview-body {
|
||||||
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header-sections:page-has-toc:top-32 page-api-block:xl:max-2xl:top-32 print-mode:static;
|
@apply flex flex-col gap-4 sticky top-4 site-header:top-20 site-header:xl:max-2xl:top-32 site-header-sections:top-32 site-header-sections:xl:max-2xl:top-44 print-mode:static;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-column-preview pre {
|
.openapi-column-preview pre {
|
||||||
@@ -155,45 +157,30 @@
|
|||||||
/* unstyled */
|
/* unstyled */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-schema-root-description.openapi-markdown {
|
||||||
|
@apply prose-sm text-balance mt-1.5 !text-[0.813rem] text-tint overflow-hidden !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root-description {
|
||||||
|
@apply px-2.5 pt-2.5 mt-0 !text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-schema-properties {
|
.openapi-schema-properties {
|
||||||
@apply flex flex-col;
|
@apply flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema {
|
.openapi-schema,
|
||||||
|
.openapi-disclosure {
|
||||||
@apply py-2.5 flex flex-col gap-2;
|
@apply py-2.5 flex flex-col gap-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-section-body .openapi-schema-properties {
|
|
||||||
@apply divide-y divide-tint-subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure-group-panel > .openapi-schema-properties > *:first-child > .openapi-schema {
|
|
||||||
@apply pt-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-responsebody > .openapi-schema-properties > .openapi-schema:last-child {
|
|
||||||
@apply pb-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-responsebody > .openapi-schema-properties > .openapi-schema:only-child {
|
|
||||||
@apply py-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-schema-properties .openapi-schema:last-child {
|
.openapi-schema-properties .openapi-schema:last-child {
|
||||||
@apply border-b-0;
|
@apply border-b-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-properties .openapi-schema-opened {
|
|
||||||
@apply pb-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-schema > .openapi-schema-properties {
|
|
||||||
@apply mt-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Schema Presentation */
|
/* Schema Presentation */
|
||||||
.openapi-schema-presentation {
|
.openapi-schema-presentation {
|
||||||
@apply flex flex-col gap-1.5 font-normal;
|
@apply flex flex-col gap-1 font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-properties:last-child {
|
.openapi-schema-properties:last-child {
|
||||||
@@ -203,7 +190,7 @@
|
|||||||
.openapi-schema-name {
|
.openapi-schema-name {
|
||||||
/* To make double click on the property name select only the name,
|
/* To make double click on the property name select only the name,
|
||||||
we disable selection on the parent and re-enable it on the children. */
|
we disable selection on the parent and re-enable it on the children. */
|
||||||
@apply select-none flex gap-x-2.5 items-baseline text-sm flex-wrap;
|
@apply select-none text-sm text-balance *:whitespace-nowrap flex flex-wrap gap-y-1.5 gap-x-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-name .openapi-deprecated {
|
.openapi-schema-name .openapi-deprecated {
|
||||||
@@ -211,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-propertyname {
|
.openapi-schema-propertyname {
|
||||||
@apply select-all font-mono font-normal text-tint-strong;
|
@apply select-all font-mono font-semibold text-tint-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-propertyname[data-deprecated="true"] {
|
.openapi-schema-propertyname[data-deprecated="true"] {
|
||||||
@@ -219,19 +206,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-required {
|
.openapi-schema-required {
|
||||||
@apply text-warning-subtle text-[0.813rem];
|
@apply text-warning-subtle text-[0.813rem] lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-optional {
|
.openapi-schema-optional {
|
||||||
@apply text-info-subtle text-[0.813rem];
|
@apply text-tint-subtle text-[0.813rem] lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-readonly {
|
.openapi-schema-readonly {
|
||||||
@apply text-primary-subtle/9 text-[0.813rem];
|
@apply text-primary-subtle/9 text-[0.813rem] lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-writeonly {
|
.openapi-schema-writeonly {
|
||||||
@apply text-success dark:text-success-subtle/9 text-[0.813rem];
|
@apply text-success dark:text-success-subtle/9 text-[0.813rem] lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schema-types {
|
||||||
|
@apply flex items-baseline flex-wrap gap-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-type {
|
.openapi-schema-type {
|
||||||
@@ -265,15 +256,11 @@
|
|||||||
|
|
||||||
/* Schema Enum */
|
/* Schema Enum */
|
||||||
.openapi-schema-enum {
|
.openapi-schema-enum {
|
||||||
@apply flex flex-row text-sm leading-relaxed gap-2 flex-wrap text-tint;
|
@apply text-sm leading-relaxed max-w-full text-tint;
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-schema-enum-list {
|
|
||||||
@apply flex flex-row gap-1.5 items-center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-enum-value {
|
.openapi-schema-enum-value {
|
||||||
@apply text-sm;
|
@apply text-sm mr-1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-enum-value:first-child {
|
.openapi-schema-enum-value:first-child {
|
||||||
@@ -286,7 +273,7 @@
|
|||||||
|
|
||||||
/* Schema Description */
|
/* Schema Description */
|
||||||
.openapi-schema-description.openapi-markdown {
|
.openapi-schema-description.openapi-markdown {
|
||||||
@apply prose-sm text-tint overflow-hidden !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
|
@apply prose-sm text-tint overflow-hidden text-pretty !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-description.openapi-markdown pre:has(code) {
|
.openapi-schema-description.openapi-markdown pre:has(code) {
|
||||||
@@ -306,14 +293,16 @@
|
|||||||
|
|
||||||
/* Schema Examples */
|
/* Schema Examples */
|
||||||
.openapi-schema-example,
|
.openapi-schema-example,
|
||||||
.openapi-schema-pattern {
|
.openapi-schema-pattern,
|
||||||
|
.openapi-schema-default {
|
||||||
@apply prose-sm text-tint;
|
@apply prose-sm text-tint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-schema-example code,
|
.openapi-schema-example code,
|
||||||
.openapi-schema-pattern code,
|
.openapi-schema-pattern code,
|
||||||
.openapi-schema-enum-value code {
|
.openapi-schema-enum-value code,
|
||||||
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
|
.openapi-schema-default code {
|
||||||
|
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:!content-none after:!content-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Authentication */
|
/* Authentication */
|
||||||
@@ -326,7 +315,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-securities-description.openapi-markdown {
|
.openapi-securities-description.openapi-markdown {
|
||||||
@apply prose-sm text-tint !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
|
@apply prose-sm text-tint !font-normal select-text text-pretty prose-strong:font-semibold prose-strong:text-inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-securities-label {
|
.openapi-securities-label {
|
||||||
@@ -352,12 +341,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-requestbody-description.openapi-markdown {
|
.openapi-requestbody-description.openapi-markdown {
|
||||||
@apply prose-sm text-tint !font-normal select-text prose-strong:font-semibold prose-strong:text-inherit;
|
@apply prose-sm text-tint !font-normal text-pretty select-text prose-strong:font-semibold prose-strong:text-inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responses */
|
/* Responses */
|
||||||
.openapi-responses-header {
|
.openapi-responses-header {
|
||||||
@apply py-2 border-b border-tint-subtle max-w-full flex-1;
|
@apply py-2 max-w-full flex-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-responses-header-content {
|
.openapi-responses-header-content {
|
||||||
@@ -365,31 +354,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-response-tab-content {
|
.openapi-response-tab-content {
|
||||||
@apply overflow-hidden max-w-full flex items-baseline gap-2;
|
@apply flex items-baseline truncate grow shrink max-w-max basis-[60%] mr-auto;
|
||||||
|
@apply text-left text-pretty relative leading-tight text-tint select-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-response-description.openapi-markdown {
|
.openapi-response-description.openapi-markdown {
|
||||||
@apply text-left prose-sm text-[0.813rem] h-auto relative leading-[1.125rem] text-tint !font-normal truncate select-text prose-strong:font-semibold prose-strong:text-inherit;
|
@apply text-left truncate prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-response-description.openapi-markdown::-webkit-scrollbar {
|
.openapi-disclosure-group-trigger[aria-expanded="true"] .openapi-response-tab-content {
|
||||||
display: none;
|
@apply basis-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-response-description p {
|
.openapi-disclosure-group-trigger[aria-expanded="true"]
|
||||||
@apply truncate max-w-full inline pr-1;
|
.openapi-response-description.openapi-markdown {
|
||||||
}
|
@apply whitespace-normal;
|
||||||
|
|
||||||
.openapi-response-statuscode {
|
|
||||||
@apply tabular-nums text-sm font-normal font-mono shrink-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-response-content-type {
|
|
||||||
@apply text-xs text-tint-8 ml-auto shrink-0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-response-body {
|
.openapi-response-body {
|
||||||
@apply flex flex-col gap-3;
|
@apply flex flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Response Body and Headers */
|
/* Response Body and Headers */
|
||||||
@@ -406,22 +389,39 @@
|
|||||||
@apply px-3 py-1;
|
@apply px-3 py-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-responsebody-header-content,
|
|
||||||
.openapi-responseheaders-header-content {
|
|
||||||
/* unstyled */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code Sample */
|
/* Code Sample */
|
||||||
.openapi-codesample {
|
|
||||||
@apply border rounded bg-tint border-tint-subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-codesample-header {
|
.openapi-codesample-header {
|
||||||
@apply flex flex-row items-center;
|
@apply flex flex-row items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-response-media-types-examples-footer-content {
|
||||||
|
@apply flex flex-row items-center gap-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-panel-heading,
|
||||||
|
.openapi-codesample-header,
|
||||||
|
.openapi-response-examples-header {
|
||||||
|
@apply border-b border-tint-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-response-examples-header .openapi-select > button {
|
||||||
|
@apply max-w-full overflow-hidden shrink pl-0.5 py-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-response-examples-header .openapi-select > button .openapi-statuscode {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-codesample-header-content {
|
.openapi-codesample-header-content {
|
||||||
@apply flex flex-row items-center h-fit;
|
@apply flex flex-row items-center justify-between h-fit p-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-codesample-header-content .openapi-path {
|
||||||
|
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
|
||||||
|
@apply text-[0.813rem];
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-codesample-footer {
|
.openapi-codesample-footer {
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
|
|
||||||
/* Path */
|
/* Path */
|
||||||
.openapi-path {
|
.openapi-path {
|
||||||
@apply flex items-start text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
|
@apply flex items-center text-sm gap-2 h-fit overflow-x-auto min-w-0 max-w-full;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
@@ -448,12 +448,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path .openapi-method {
|
.openapi-path .openapi-method {
|
||||||
@apply text-[0.813rem] m-0 mt-0.5 items-center flex px-2;
|
@apply m-0 mt-0.5 items-center flex px-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-path-title {
|
.openapi-path-title {
|
||||||
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
|
@apply flex-1 relative font-normal text-left font-mono text-tint-strong/10;
|
||||||
@apply py-0.5 px-1 rounded hover:bg-tint cursor-pointer transition-colors;
|
@apply py-0.5 px-1 rounded hover:bg-tint transition-colors;
|
||||||
@apply whitespace-nowrap md:whitespace-normal;
|
@apply whitespace-nowrap md:whitespace-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,14 +465,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .openapi-path-copy {
|
|
||||||
@apply absolute opacity-0 h-fit right-0 top-1/2 -translate-y-1/2 bg-light dark:bg-dark border rounded-md border-tint-subtle px-1.5 py-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-path-title:hover .openapi-path-copy {
|
|
||||||
@apply opacity-11;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.openapi-path-title em {
|
.openapi-path-title em {
|
||||||
@apply not-italic text-primary font-medium;
|
@apply not-italic text-primary font-medium;
|
||||||
}
|
}
|
||||||
@@ -485,18 +477,131 @@
|
|||||||
@apply flex flex-row items-center py-2 px-3 justify-end border-t border-tint-subtle;
|
@apply flex flex-row items-center py-2 px-3 justify-end border-t border-tint-subtle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Response Example */
|
/* Panel */
|
||||||
.openapi-response-example {
|
.openapi-panel,
|
||||||
@apply border rounded bg-tint border-tint-subtle;
|
.openapi-codesample,
|
||||||
|
.openapi-response-examples {
|
||||||
|
@apply border rounded-md straight-corners:rounded-none bg-tint-subtle border-tint-subtle shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-response-example-empty {
|
.openapi-panel pre,
|
||||||
|
.openapi-codesample pre,
|
||||||
|
.openapi-response-examples pre {
|
||||||
|
@apply bg-transparent border-none rounded-none shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-panel-heading {
|
||||||
|
@apply font-medium px-4 py-2 text-xs uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-panel-body {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-panel-footer,
|
||||||
|
.openapi-codesample-footer {
|
||||||
|
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint empty:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-panel-footer .openapi-markdown {
|
||||||
|
@apply text-[0.813rem] text-tint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Example */
|
||||||
|
.openapi-response-examples-header {
|
||||||
|
@apply flex flex-row items-center p-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-response-examples-header-content {
|
||||||
|
@apply max-w-full overflow-hidden truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-response-examples-statuscode-title {
|
||||||
|
@apply flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-response-examples-header .openapi-select > button,
|
||||||
|
.openapi-response-examples-header .openapi-markdown,
|
||||||
|
.openapi-response-examples-statuscode-title {
|
||||||
|
@apply text-[0.813rem] truncate text-tint font-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-response-examples-panel,
|
||||||
|
.openapi-codesample-panel {
|
||||||
|
@apply flex-1 text-sm relative focus-visible:outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-example-empty {
|
||||||
@apply relative text-tint bg-tint min-h-20 flex flex-col justify-center items-center;
|
@apply relative text-tint bg-tint min-h-20 flex flex-col justify-center items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Common Elements */
|
/* Common Elements */
|
||||||
.openapi-select {
|
.openapi-select {
|
||||||
@apply max-w-60 rounded font-mono text-xs leading-6 px-1 py-0.5 truncate border border-tint-subtle bg-tint;
|
@apply w-auto max-w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent react-aria popover from setting overflow:auto on body */
|
||||||
|
body:has(.openapi-select-popover) {
|
||||||
|
overflow: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select > button {
|
||||||
|
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 text-tint-strong max-w-32 rounded text-xs p-1.5 leading-none border border-tint-subtle bg-tint;
|
||||||
|
@apply hover:bg-tint-hover transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select > button[data-focused="true"] {
|
||||||
|
@apply outline-primary -outline-offset-1 outline outline-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select > button > span.react-aria-SelectValue {
|
||||||
|
@apply shrink truncate flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select > button > span.react-aria-SelectValue span:not(.openapi-statuscode) {
|
||||||
|
@apply truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select > button .openapi-markdown {
|
||||||
|
@apply *:leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select > button .gb-icon {
|
||||||
|
@apply size-2.5 shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-popover {
|
||||||
|
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none;
|
||||||
|
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-popover[data-entering] {
|
||||||
|
animation: popover-enter 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-popover[data-exiting] {
|
||||||
|
animation: popover-leave 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-item {
|
||||||
|
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 *:truncate text-tint ring-0 border-none rounded !outline-none;
|
||||||
|
@apply hover:bg-tint-hover theme-gradient:hover:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select button .openapi-markdown,
|
||||||
|
.openapi-select-item .openapi-markdown {
|
||||||
|
@apply text-[0.813rem] *:truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-item-selected,
|
||||||
|
.openapi-select-item-selected .openapi-markdown {
|
||||||
|
@apply text-primary-subtle hover:text-primary hover:bg-primary-hover;
|
||||||
|
@apply theme-muted:hover:bg-primary-active theme-gradient:hover:bg-primary-active tint:font-semibold;
|
||||||
|
@apply contrast-more:text-primary contrast-more:hover:text-primary-strong contrast-more:font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-select-listbox {
|
||||||
|
@apply flex flex-col gap-1 focus:ring-0 focus:outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-select:focus {
|
.openapi-select:focus {
|
||||||
@@ -524,6 +629,14 @@
|
|||||||
@apply text-tint;
|
@apply text-tint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-section-footer {
|
||||||
|
@apply flex flex-row items-center p-2.5 gap-2.5 text-sm text-tint-strong border-t border-tint-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-section-footer-content {
|
||||||
|
@apply text-sm text-tint-strong;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-section-toggle {
|
.openapi-section-toggle {
|
||||||
@apply text-tint-subtle contrast-more:text-tint-strong;
|
@apply text-tint-subtle contrast-more:text-tint-strong;
|
||||||
}
|
}
|
||||||
@@ -545,6 +658,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
|
.openapi-panel-header,
|
||||||
.openapi-tabs-list {
|
.openapi-tabs-list {
|
||||||
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
|
@apply flex flex-row gap-1.5 py-1.5 px-2.5 w-full overflow-x-scroll;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -556,7 +670,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tabs-tab[aria-selected="true"] {
|
.openapi-tabs-tab[aria-selected="true"] {
|
||||||
@apply text-primary after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
|
@apply !text-primary-subtle after:absolute after:-bottom-[calc(0.375rem_+_1px)] after:z-20 after:left-0 after:w-full after:h-px after:bg-primary-solid after:transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tabs-panel {
|
.openapi-tabs-panel {
|
||||||
@@ -564,44 +678,35 @@
|
|||||||
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
|
@apply before:w-full before:h-px before:absolute before:bg-tint-6 before:-top-px before:z-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tabs-footer {
|
|
||||||
@apply px-3 py-2 pt-2.5 border-t border-tint-subtle text-[0.813rem] text-tint;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-tabs-footer .openapi-markdown {
|
|
||||||
@apply text-[0.813rem] text-tint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disclosure group */
|
/* Disclosure group */
|
||||||
.openapi-disclosure-group {
|
.openapi-disclosure-group {
|
||||||
@apply border-b border-tint-subtle relative;
|
@apply border-tint-subtle transition-all border-b border-x overflow-auto last:rounded-b-md straight-corners:last:rounded-none first:rounded-t-md straight-corners:first:rounded-none first:border-t relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-header {
|
.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover) {
|
||||||
@apply flex flex-row items-baseline justify-between gap-3 relative;
|
@apply bg-tint-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-group:has(.openapi-disclosure-group-trigger:hover):has(.openapi-select:hover) {
|
||||||
|
@apply !bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-trigger {
|
.openapi-disclosure-group-trigger {
|
||||||
@apply flex items-baseline relative flex-1 gap-2.5 py-2 truncate -outline-offset-1;
|
@apply flex w-full cursor-pointer items-baseline gap-3 transition-all relative flex-1 p-3 -outline-offset-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-trigger:disabled {
|
.openapi-disclosure-group-label {
|
||||||
@apply cursor-default;
|
@apply flex flex-wrap items-baseline gap-x-3 gap-y-1 flex-1 truncate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group:only-child,
|
.openapi-disclosure-group-trigger[aria-disabled="true"] {
|
||||||
.openapi-disclosure-group:last-child {
|
@apply cursor-default hover:bg-inherit;
|
||||||
@apply border-b-0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-trigger:disabled .openapi-disclosure-group-icon {
|
.openapi-disclosure-group-trigger[aria-disabled="true"] .openapi-disclosure-group-icon {
|
||||||
@apply invisible;
|
@apply invisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-trigger[aria-expanded="true"] .openapi-response-description {
|
|
||||||
@apply whitespace-normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure-group-icon > svg {
|
.openapi-disclosure-group-icon > svg {
|
||||||
@apply size-3 text-tint-subtle transition-all duration-300;
|
@apply size-3 text-tint-subtle transition-all duration-300;
|
||||||
}
|
}
|
||||||
@@ -611,82 +716,139 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-panel {
|
.openapi-disclosure-group-panel {
|
||||||
@apply pb-2.5;
|
@apply px-3 transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg {
|
.openapi-disclosure-group-trigger[aria-expanded="true"] > .openapi-disclosure-group-icon > svg {
|
||||||
@apply rotate-90;
|
@apply rotate-90;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-group:hover .openapi-disclosure-group-mediatype {
|
.openapi-disclosure-group-mediatype:not(:has(.openapi-select)) {
|
||||||
@apply opacity-11 flex;
|
@apply text-[0.625rem] font-mono shrink-0 grow-0 text-tint-subtle contrast-more:text-tint;
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure-group-mediatype {
|
|
||||||
@apply opacity-0 hidden text-xs transition-all duration-200 shrink-0 absolute right-0 top-2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure-group-mediatype > span {
|
|
||||||
@apply px-1 bg-tint-6 text-tint-12 rounded-full;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disclosure */
|
/* Disclosure */
|
||||||
|
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
||||||
|
@apply flex items-center font-mono transition-all text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-3 truncate -outline-offset-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
|
||||||
|
.openapi-schemas-disclosure .openapi-disclosure-panel {
|
||||||
|
@apply straight-corners:!rounded-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-panel {
|
||||||
|
@apply ml-1.5 pl-3 border-l border-tint-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schema .openapi-schema-properties .openapi-schema {
|
||||||
|
@apply animate-fadeIn [animation-fill-mode:both];
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-schemas-disclosure > .openapi-disclosure-trigger[aria-expanded="true"] > svg {
|
||||||
|
@apply rotate-90;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-disclosure-trigger {
|
.openapi-disclosure-trigger {
|
||||||
@apply transition-all truncate duration-300 max-w-full hover:text-tint-strong rounded-2xl border border-tint-subtle px-2.5 py-1 text-[0.813rem] text-tint flex flex-row items-center gap-1.5 -outline-offset-1;
|
@apply flex flex-row justify-between flex-wrap relative items-start gap-2 text-left -mx-3 px-3 -my-2.5 py-2.5 pr-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-trigger span {
|
.openapi-disclosure {
|
||||||
@apply truncate;
|
@apply -mx-3 px-3 py-2.5 transition-all flex flex-col ring-tint-subtle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure svg {
|
.openapi-disclosure:not(
|
||||||
@apply size-3 shrink-0 transition-transform duration-300;
|
.openapi-disclosure-group .openapi-disclosure,
|
||||||
|
.openapi-schema-alternatives .openapi-disclosure,
|
||||||
|
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
|
||||||
|
) {
|
||||||
|
@apply rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
|
||||||
|
@apply !rounded-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure:has(> .openapi-disclosure-trigger:hover) {
|
||||||
|
@apply bg-tint-subtle overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure:has(> .openapi-disclosure-trigger:hover),
|
||||||
|
.openapi-disclosure[data-expanded="true"] {
|
||||||
|
@apply ring-1 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) {
|
||||||
|
@apply mt-2;
|
||||||
|
}
|
||||||
|
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) {
|
||||||
|
@apply mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-trigger-label {
|
||||||
|
@apply absolute right-3 px-2 h-5 justify-end shrink-0 ring-tint-subtle truncate text-tint duration-300 transition-all rounded straight-corners:rounded-none flex flex-row gap-1 items-center text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-trigger-label span {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-trigger-label svg {
|
||||||
|
@apply size-3 shrink-0 transition-transform duration-300 text-tint-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-trigger:hover > .openapi-disclosure-trigger-label,
|
||||||
|
.openapi-disclosure-trigger[aria-expanded="true"] > .openapi-disclosure-trigger-label {
|
||||||
|
@apply shadow ring-1 bg-tint-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-disclosure-trigger:hover > .openapi-disclosure-trigger-label span,
|
||||||
|
.openapi-disclosure-trigger[aria-expanded="true"] > .openapi-disclosure-trigger-label span {
|
||||||
|
@apply block animate-fadeIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
/* Make button label always visible on non-hover devices like phones */
|
||||||
|
.openapi-disclosure-trigger-label {
|
||||||
|
@apply relative ring-1 bg-tint-base right-0;
|
||||||
|
}
|
||||||
|
.openapi-disclosure-trigger-label span {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
.openapi-disclosure-trigger {
|
||||||
|
@apply pr-3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-trigger[aria-expanded="true"] svg {
|
.openapi-disclosure-trigger[aria-expanded="true"] svg {
|
||||||
@apply rotate-45;
|
@apply rotate-45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-trigger[aria-expanded="true"] {
|
|
||||||
@apply w-full rounded-lg border-b rounded-b-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure-trigger[aria-expanded="false"] {
|
.openapi-disclosure-trigger[aria-expanded="false"] {
|
||||||
@apply w-auto;
|
@apply w-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-disclosure-panel[aria-hidden="false"] {
|
|
||||||
@apply border-b border-x border-tint-subtle rounded-b-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure-panel .openapi-schema {
|
|
||||||
@apply p-2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure .openapi-schema-properties .openapi-schema:only-child,
|
|
||||||
.openapi-disclosure .openapi-schema-properties .openapi-schema:only-child .openapi-schema-name {
|
|
||||||
@apply !m-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-disclosure .openapi-schema-properties .openapi-schema-enum {
|
|
||||||
@apply pt-0 mt-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-section-body.openapi-schema.openapi-schema-root {
|
.openapi-section-body.openapi-schema.openapi-schema-root {
|
||||||
@apply space-y-2.5;
|
@apply space-y-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-section-schemas {
|
|
||||||
@apply border border-tint-subtle rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema,
|
.openapi-section-schemas > .openapi-section-body > .openapi-schema-properties > .openapi-schema,
|
||||||
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root {
|
.openapi-section-schemas > .openapi-section-body > .openapi-schema-root {
|
||||||
@apply p-2.5;
|
@apply p-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.openapi-schema-alternatives {
|
||||||
|
@apply ml-1.5 pl-3 border-l border-tint-subtle;
|
||||||
|
}
|
||||||
|
.openapi-schema-alternative {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
.openapi-schema-alternative-separator {
|
||||||
|
@apply p-0.5 tracking-wide leading-none uppercase text-[0.625rem] text-tint-subtle whitespace-nowrap absolute -left-3 -bottom-2.5 -translate-x-1/2 z-10 bg-tint-base border-y border-tint-subtle -rotate-6;
|
||||||
|
}
|
||||||
|
|
||||||
.openapi-tooltip {
|
.openapi-tooltip {
|
||||||
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md font-medium px-1.5 py-0.5 shadow-sm text-[13px];
|
@apply flex items-center gap-1 bg-tint-base border border-tint-subtle text-tint-strong rounded-md straight-corners:rounded-none font-medium px-1.5 py-0.5 shadow-sm text-[13px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-tooltip svg {
|
.openapi-tooltip svg {
|
||||||
@@ -719,6 +881,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.openapi-copy-button {
|
@keyframes popover-enter {
|
||||||
@apply hover:brightness-95;
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px) scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popover-leave {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px) scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-copy-button {
|
||||||
|
@apply hover:brightness-95 cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openapi-copy-button[data-disabled="true"] {
|
||||||
|
@apply cursor-default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { DocumentBlockReusableContent } from '@gitbook/api';
|
|||||||
|
|
||||||
import { resolveContentRef } from '@/lib/references';
|
import { resolveContentRef } from '@/lib/references';
|
||||||
|
|
||||||
|
import type { GitBookSpaceContext } from '@v2/lib/context';
|
||||||
import { getDataOrNull } from '@v2/lib/data';
|
import { getDataOrNull } from '@v2/lib/data';
|
||||||
import type { BlockProps } from './Block';
|
import type { BlockProps } from './Block';
|
||||||
import { UnwrappedBlocks } from './Blocks';
|
import { UnwrappedBlocks } from './Blocks';
|
||||||
@@ -13,15 +14,28 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
|
|||||||
throw new Error('Expected a content context to render a reusable content block');
|
throw new Error('Expected a content context to render a reusable content block');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = await resolveContentRef(block.data.ref, context.contentContext);
|
const dataFetcher = block.meta?.token
|
||||||
if (!resolved?.reusableContent?.document) {
|
? context.contentContext.dataFetcher.withToken({ apiToken: block.meta.token })
|
||||||
|
: context.contentContext.dataFetcher;
|
||||||
|
|
||||||
|
const resolved = await resolveContentRef(block.data.ref, {
|
||||||
|
...context.contentContext,
|
||||||
|
dataFetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolved?.reusableContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reusableContent = resolved.reusableContent.revisionReusableContent;
|
||||||
|
if (!reusableContent.document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = await getDataOrNull(
|
const document = await getDataOrNull(
|
||||||
context.contentContext.dataFetcher.getDocument({
|
dataFetcher.getDocument({
|
||||||
spaceId: context.contentContext.space.id,
|
spaceId: resolved.reusableContent.space.id,
|
||||||
documentId: resolved.reusableContent.document,
|
documentId: reusableContent.document,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -29,12 +43,34 @@ export async function ReusableContent(props: BlockProps<DocumentBlockReusableCon
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new context for reusable content block, including
|
||||||
|
// the data fetcher with the token from the block meta and the correct
|
||||||
|
// space and revision pointers.
|
||||||
|
const reusableContentContext: GitBookSpaceContext =
|
||||||
|
context.contentContext.space.id === resolved.reusableContent.space.id
|
||||||
|
? context.contentContext
|
||||||
|
: {
|
||||||
|
...context.contentContext,
|
||||||
|
dataFetcher,
|
||||||
|
space: resolved.reusableContent.space,
|
||||||
|
revisionId: resolved.reusableContent.revision,
|
||||||
|
// When the reusable content is in a different space, we don't resolve relative links to pages
|
||||||
|
// as this space might not be part of the current site.
|
||||||
|
// In the future, we might expand the logic to look up the space from the list of all spaces in the site
|
||||||
|
// and adapt the relative links to point to the correct variant.
|
||||||
|
pages: [],
|
||||||
|
shareKey: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnwrappedBlocks
|
<UnwrappedBlocks
|
||||||
nodes={document.nodes}
|
nodes={document.nodes}
|
||||||
document={document}
|
document={document}
|
||||||
ancestorBlocks={[...ancestorBlocks, block]}
|
ancestorBlocks={[...ancestorBlocks, block]}
|
||||||
context={context}
|
context={{
|
||||||
|
...context,
|
||||||
|
contentContext: reusableContentContext,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
|
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const alignment = getColumnAlignment(definition);
|
const horizontalAlignment = `[&_*]:${getColumnAlignment(definition)} ${getColumnAlignment(definition)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Blocks
|
<Blocks
|
||||||
@@ -130,8 +130,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
'lg:space-y-3',
|
'lg:space-y-3',
|
||||||
'leading-normal',
|
'leading-normal',
|
||||||
verticalAlignment,
|
verticalAlignment,
|
||||||
alignment === 'right' ? 'text-right' : null,
|
horizontalAlignment,
|
||||||
alignment === 'center' ? 'text-center' : null,
|
|
||||||
]}
|
]}
|
||||||
context={context}
|
context={context}
|
||||||
blockStyle={['w-full', 'max-w-[unset]']}
|
blockStyle={['w-full', 'max-w-[unset]']}
|
||||||
@@ -168,7 +167,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
key={index}
|
key={index}
|
||||||
href={ref.href}
|
href={ref.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style={['flex', 'flex-row', 'items-center', 'gap-2']}
|
className="flex flex-row items-center gap-2"
|
||||||
insights={
|
insights={
|
||||||
ref.file
|
ref.file
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ export function RecordRow(
|
|||||||
fixedColumns: string[];
|
fixedColumns: string[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { view, autoSizedColumns, fixedColumns, block } = props;
|
const { view, autoSizedColumns, fixedColumns, block, context } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} role="row">
|
<div className={styles.row} role="row">
|
||||||
{view.columns.map((column) => {
|
{view.columns.map((column) => {
|
||||||
const columnWidth = getColumnWidth({
|
const columnWidth = getColumnWidth({
|
||||||
column,
|
column,
|
||||||
columnWidths: view.columnWidths,
|
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
|
||||||
autoSizedColumns,
|
autoSizedColumns,
|
||||||
fixedColumns,
|
fixedColumns,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import { getColumnAlignment } from './utils';
|
|||||||
3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
|
3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
|
||||||
*/
|
*/
|
||||||
export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
|
export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
|
||||||
const { block, view, records, style } = props;
|
const { block, view, records, style, context } = props;
|
||||||
|
|
||||||
/* Calculate how many columns are auto-sized vs fixed width */
|
/* Calculate how many columns are auto-sized vs fixed width */
|
||||||
const columnWidths = view.columnWidths;
|
const columnWidths = context.mode === 'print' ? undefined : view.columnWidths;
|
||||||
const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
|
const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
|
||||||
const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
|
const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
|
||||||
|
|
||||||
@@ -42,32 +42,28 @@ export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div role="row" className={tcls('flex', 'w-full')}>
|
<div role="row" className={tcls('flex', 'w-full')}>
|
||||||
{view.columns.map((column) => {
|
{view.columns.map((column) => (
|
||||||
const alignment = getColumnAlignment(block.data.definition[column]);
|
<div
|
||||||
return (
|
key={column}
|
||||||
<div
|
role="columnheader"
|
||||||
key={column}
|
className={tcls(
|
||||||
role="columnheader"
|
styles.columnHeader,
|
||||||
className={tcls(
|
getColumnAlignment(block.data.definition[column])
|
||||||
styles.columnHeader,
|
)}
|
||||||
alignment === 'right' ? 'text-right' : null,
|
style={{
|
||||||
alignment === 'center' ? 'text-center' : null
|
width: getColumnWidth({
|
||||||
)}
|
column,
|
||||||
style={{
|
columnWidths,
|
||||||
width: getColumnWidth({
|
autoSizedColumns,
|
||||||
column,
|
fixedColumns,
|
||||||
columnWidths,
|
}),
|
||||||
autoSizedColumns,
|
minWidth: columnWidths?.[column] || '100px',
|
||||||
fixedColumns,
|
}}
|
||||||
}),
|
title={block.data.definition[column].title}
|
||||||
minWidth: columnWidths?.[column] || '100px',
|
>
|
||||||
}}
|
{block.data.definition[column].title}
|
||||||
title={block.data.definition[column].title}
|
</div>
|
||||||
>
|
))}
|
||||||
{block.data.definition[column].title}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.columnHeader {
|
.columnHeader {
|
||||||
@apply text-sm font-medium py-2 px-4 text-tint-strong;
|
@apply text-sm font-medium py-2 px-3 text-tint-strong;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api';
|
import type { ContentRef, DocumentTableDefinition, DocumentTableRecord } from '@gitbook/api';
|
||||||
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the value for a column in a record.
|
* Get the value for a column in a record.
|
||||||
@@ -14,11 +15,24 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
|||||||
/**
|
/**
|
||||||
* Get the text alignment for a column.
|
* Get the text alignment for a column.
|
||||||
*/
|
*/
|
||||||
export function getColumnAlignment(column: DocumentTableDefinition): 'left' | 'right' | 'center' {
|
export function getColumnAlignment(column: DocumentTableDefinition) {
|
||||||
|
const defaultAlignment = 'text-left';
|
||||||
|
|
||||||
if (column.type === 'text') {
|
if (column.type === 'text') {
|
||||||
return column.textAlignment ?? 'left';
|
switch (column.textAlignment) {
|
||||||
|
case undefined:
|
||||||
|
case 'left':
|
||||||
|
return defaultAlignment;
|
||||||
|
case 'center':
|
||||||
|
return 'text-center';
|
||||||
|
case 'right':
|
||||||
|
return 'text-right';
|
||||||
|
default:
|
||||||
|
assertNever(column.textAlignment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'left';
|
|
||||||
|
return defaultAlignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user