mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-11-02 18:23:49 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44ae7afc37 | |||
| b2859600bc | |||
| d99da6a3ae | |||
| 72cd0e59e6 | |||
| 42d88da73c | |||
| aabdd3f4d7 | |||
| a2fc076acb | |||
| 82ed911cd8 | |||
| 7b4908c8fd | |||
| 2863fe0dc1 | |||
| de19ada4fa | |||
| 47c80991d3 | |||
| 9e582ed9f0 | |||
| 87218062be | |||
| 8a3910e208 | |||
| 6294bbb53c | |||
| a28a997851 | |||
| 28a9ee701e | |||
| 50e8b2ec52 | |||
| 315717fa2d | |||
| da67711c90 | |||
| fecdbe8dde | |||
| 9316ccd1f4 | |||
| c3b620e975 | |||
| 7a00880e5b | |||
| b6b597564d | |||
| d410381957 | |||
| 902c3c6c1a | |||
| 4fb2a4a6d7 | |||
| 2d64c78787 | |||
| fbfcca5dae | |||
| 664ae8bff6 | |||
| fa3d2aae08 | |||
| e7a591d9f6 | |||
| 2860beacdc | |||
| 231167d3e1 | |||
| c0ee60ec13 | |||
| f58b904ba6 | |||
| d5992f1a5c | |||
| fa12f9eeb6 | |||
| 33726c88a0 | |||
| 6aa3ff9c5e | |||
| 7d3fe23195 | |||
| 521052d84f | |||
| c730845e17 | |||
| 7b5a3616c4 | |||
| c9373efdb5 | |||
| 015615de8a | |||
| 0f867e533a | |||
| 7d5a6d2255 | |||
| a1da4f023d | |||
| 666e8426e1 | |||
| df2fa42bda | |||
| b3a7ad6d2c | |||
| 2932077bf9 | |||
| 957afd967f | |||
| 4c9a9d0416 | |||
| 40df91a363 | |||
| af66ff71ed | |||
| 6f8ecd6111 | |||
| fa3eb07617 | |||
| dc4268db64 | |||
| a0c06a72cc | |||
| 19c20c0be3 | |||
| fae34e56ac | |||
| ba0094a862 | |||
| b259009c9c | |||
| d88dd5ed8a | |||
| 57bb146075 | |||
| 4b67fe5317 | |||
| 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 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix UX issue about highlighting the search term in search result sections
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix three small visual issues
|
||||||
|
|
||||||
|
- Fix sidebar showing on `no-toc` pages in the gradient theme
|
||||||
|
- Fix variant selector truncating incorrectly in header when sections are present
|
||||||
|
- Fix page cover alignment on `lg` screens without TOC
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Respect fullWidth and defaultWidth for images
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add docs.testgitbook.com to ADAPTIVE_CONTENT_HOSTS list
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix nested a tag causing hydration error
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix href being empty in TOC
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix navigation between sections/variants when previewing a site in v2
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add circular corners and depth styling
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add authorization header for OAuth2
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve support for OAuth2 security type
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds Columns layout block to GBO
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Support dark-mode specific page cover image
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Update Models page styling
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Optimize performances by using a smarter per-request cache arround data cached functions
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
add a force-revalidate api route to force bust the cache in case of errors
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Best effort at preserving current variant when navigating between sections by matching the pathname against site spaces in the new section.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
encode customization header
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Rework full-width layout, add support for full-width page option
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix ISR on preview env
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix concurrent execution in Vercel causing pages to not be attached to the proper tags.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Generate a llms-full.txt version of the docs site
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/fonts": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Initial version of the package
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add margin to adjacent buttons
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
add a global error boundary
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Indent JSON python code sample
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix missing title on button to close the announcement banner.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Make icons for page groups more contrasting
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Ignore case while highlighting search results.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Make TOC height dynamic based on visible header and footer elements
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix bold header links hover color
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
cache fonts and static image used in OGImage in memory
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix border being added to cards
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Handle nested deprecated properties in generateSchemaExample
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add `urlObject.hash` to `linker.toLinkForContent` to pass through URL fragment identifiers, used in search
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
apply customization for dynamic context
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Reverse order of feedback smileys
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Deduplicate path parameters from OpenAPI spec
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix ogimage using incorrect Google Font depending on language.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook-v2": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
remove trailing slash from linker
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Gradual Deploy to Cloudflare
|
||||||
|
description: Use gradual deployment to deploy to Cloudflare. This action will upload the middleware and server versions to Cloudflare and kept them bound together
|
||||||
|
inputs:
|
||||||
|
apiToken:
|
||||||
|
description: 'Cloudflare API token'
|
||||||
|
required: true
|
||||||
|
accountId:
|
||||||
|
description: 'Cloudflare account ID'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Cloudflare environment to deploy to (staging, production, preview)'
|
||||||
|
required: true
|
||||||
|
middlewareVersionId:
|
||||||
|
description: 'Middleware version ID to deploy'
|
||||||
|
required: true
|
||||||
|
serverVersionId:
|
||||||
|
description: 'Server version ID to deploy'
|
||||||
|
required: true
|
||||||
|
outputs:
|
||||||
|
deployment-url:
|
||||||
|
description: "Deployment URL"
|
||||||
|
value: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- id: wrangler_status
|
||||||
|
name: Check wrangler deployment status
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: deployments status --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
# This step is used to get the version ID that is currently deployed to Cloudflare.
|
||||||
|
- id: extract_current_version
|
||||||
|
name: Extract current version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- id: deploy_server
|
||||||
|
name: Deploy server to Cloudflare at 0%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
# Since we use version overrides headers, we can directly deploy the middleware to 100%.
|
||||||
|
- id: deploy_middleware
|
||||||
|
name: Deploy middleware to Cloudflare at 100%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Deploy server to Cloudflare at 100%
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Outputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }}
|
||||||
|
run: |
|
||||||
|
echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}"
|
||||||
@@ -28,7 +28,7 @@ inputs:
|
|||||||
outputs:
|
outputs:
|
||||||
deployment-url:
|
deployment-url:
|
||||||
description: "Deployment URL"
|
description: "Deployment URL"
|
||||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
@@ -63,8 +63,8 @@ runs:
|
|||||||
env:
|
env:
|
||||||
GITBOOK_RUNTIME: cloudflare
|
GITBOOK_RUNTIME: cloudflare
|
||||||
shell: bash
|
shell: bash
|
||||||
- id: deploy
|
|
||||||
name: Deploy to Cloudflare
|
- name: Upload the DO worker
|
||||||
uses: cloudflare/wrangler-action@v3.14.0
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
@@ -72,10 +72,67 @@ runs:
|
|||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.10.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
|
command: deploy --config ./packages/gitbook-v2/openNext/customWorkers/doWrangler.jsonc
|
||||||
|
|
||||||
|
- id: upload_server
|
||||||
|
name: Upload server to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Extract server version worker ID
|
||||||
|
shell: bash
|
||||||
|
id: extract_server_version_id
|
||||||
|
run: |
|
||||||
|
version_id=$(echo '${{ steps.upload_server.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Run updateWrangler scripts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bun run ./packages/gitbook-v2/openNext/customWorkers/script/updateWrangler.ts ${{ steps.extract_server_version_id.outputs.version_id }}
|
||||||
|
|
||||||
|
- id: upload_middleware
|
||||||
|
name: Upload middleware to Cloudflare
|
||||||
|
uses: cloudflare/wrangler-action@v3.14.0
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
workingDirectory: ./
|
||||||
|
wranglerVersion: '4.10.0'
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
|
- name: Extract middleware version worker ID
|
||||||
|
shell: bash
|
||||||
|
id: extract_middleware_version_id
|
||||||
|
run: |
|
||||||
|
version_id=$(echo '${{ steps.upload_middleware.outputs.command-output }}' | grep "Worker Version ID" | awk '{print $4}')
|
||||||
|
echo "version_id=$version_id" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Deploy server and middleware to Cloudflare
|
||||||
|
if: ${{ inputs.deploy == 'true' }}
|
||||||
|
uses: ./.github/actions/gradual-deploy-cloudflare
|
||||||
|
with:
|
||||||
|
apiToken: ${{ inputs.apiToken }}
|
||||||
|
accountId: ${{ inputs.accountId }}
|
||||||
|
opServiceAccount: ${{ inputs.opServiceAccount }}
|
||||||
|
opItem: ${{ inputs.opItem }}
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
serverVersionId: ${{ steps.extract_server_version_id.outputs.version_id }}
|
||||||
|
middlewareVersionId: ${{ steps.extract_middleware_version_id.outputs.version_id }}
|
||||||
|
deploy: ${{ inputs.deploy }}
|
||||||
|
|
||||||
|
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||||
run: |
|
run: |
|
||||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
echo "URL: ${{ steps.upload_middleware.outputs.deployment-url }}"
|
||||||
|
echo "Output server: ${{ steps.upload_server.outputs.command-output }}"
|
||||||
@@ -152,14 +152,14 @@ jobs:
|
|||||||
|
|
||||||
| Site | `v1` | `2v` | `2c` |
|
| 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) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.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) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
|
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ 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
|
||||||
name: Visual Testing v1
|
name: Visual Testing v1
|
||||||
needs: deploy-v1-cloudflare
|
needs: deploy-v1-cloudflare
|
||||||
timeout-minutes: 8
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -200,7 +200,6 @@ jobs:
|
|||||||
name: Visual Testing v2 (Cloudflare)
|
name: Visual Testing v2 (Cloudflare)
|
||||||
needs: deploy-v2-cloudflare
|
needs: deploy-v2-cloudflare
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -221,7 +220,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v1
|
name: Visual Testing Customers v1
|
||||||
needs: deploy-v1-cloudflare
|
needs: deploy-v1-cloudflare
|
||||||
timeout-minutes: 8
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -241,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: 8
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -262,8 +261,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Visual Testing Customers v2 (Cloudflare)
|
name: Visual Testing Customers v2 (Cloudflare)
|
||||||
needs: deploy-v2-cloudflare
|
needs: deploy-v2-cloudflare
|
||||||
timeout-minutes: 8
|
timeout-minutes: 10
|
||||||
if: startsWith(github.head_ref || github.ref_name, 'cloudflare/') || github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
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">
|
||||||
@@ -56,17 +56,23 @@ git clone https://github.com/gitbookIO/gitbook.git
|
|||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start your local development server.
|
4. Run build.
|
||||||
|
|
||||||
|
```
|
||||||
|
bun build:v2
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start your local development server.
|
||||||
|
|
||||||
```
|
```
|
||||||
bun dev:v2
|
bun dev:v2
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
|
|
||||||
- http://localhost:3000/url/docs.gitbook.com
|
- http://localhost:3000/url/gitbook.com/docs
|
||||||
- http://localhost:3000/url/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.
|
||||||
@@ -150,11 +156,11 @@ See `LICENSE` for more information.
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
```md
|
```md
|
||||||
[](https://gitbook.com/)
|
[](https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link)
|
||||||
```
|
```
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a href="https://gitbook.com">
|
<a href="https://www.gitbook.com/preview?utm_source=gitbook_readme_badge&utm_medium=organic&utm_campaign=preview_documentation&utm_content=link">
|
||||||
<img
|
<img
|
||||||
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+9
-5
@@ -7,12 +7,11 @@
|
|||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.8",
|
"packageManager": "bun@1.2.15",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"react": "18.3.1",
|
"react": "^19.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "^19.0.0"
|
||||||
"@gitbook/api": "0.111.0"
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,7 +33,12 @@
|
|||||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||||
"clean": "turbo run clean"
|
"clean": "turbo run clean"
|
||||||
},
|
},
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": {
|
||||||
|
"packages": ["packages/*"],
|
||||||
|
"catalog": {
|
||||||
|
"@gitbook/api": "^0.120.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.111.0",
|
"@gitbook/api": "catalog:",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# @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
|
## 0.3.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.2",
|
"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 === 9 ? 1 : index * 0.05;
|
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
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
src/data/*.json
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# `@gitbook/fonts`
|
||||||
|
|
||||||
|
Utilities to lookup default fonts supported by GitBook.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { APIv2 } from 'google-font-metadata';
|
||||||
|
|
||||||
|
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
|
||||||
|
import type { FontDefinitions } from '../src/types';
|
||||||
|
|
||||||
|
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
|
||||||
|
[CustomizationDefaultFont.Inter]: 'inter',
|
||||||
|
[CustomizationDefaultFont.FiraSans]: 'fira-sans-extra-condensed',
|
||||||
|
[CustomizationDefaultFont.IBMPlexSerif]: 'ibm-plex-serif',
|
||||||
|
[CustomizationDefaultFont.Lato]: 'lato',
|
||||||
|
[CustomizationDefaultFont.Merriweather]: 'merriweather',
|
||||||
|
[CustomizationDefaultFont.NotoSans]: 'noto-sans',
|
||||||
|
[CustomizationDefaultFont.OpenSans]: 'open-sans',
|
||||||
|
[CustomizationDefaultFont.Overpass]: 'overpass',
|
||||||
|
[CustomizationDefaultFont.Poppins]: 'poppins',
|
||||||
|
[CustomizationDefaultFont.Raleway]: 'raleway',
|
||||||
|
[CustomizationDefaultFont.Roboto]: 'roboto',
|
||||||
|
[CustomizationDefaultFont.RobotoSlab]: 'roboto-slab',
|
||||||
|
[CustomizationDefaultFont.SourceSansPro]: 'source-sans-3',
|
||||||
|
[CustomizationDefaultFont.Ubuntu]: 'ubuntu',
|
||||||
|
[CustomizationDefaultFont.ABCFavorit]: 'inter',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scripts to generate the list of all icons.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
// @ts-expect-error - we build the object
|
||||||
|
const output: FontDefinitions = {};
|
||||||
|
|
||||||
|
for (const font of Object.values(CustomizationDefaultFont)) {
|
||||||
|
const googleFontName = googleFontsMap[font];
|
||||||
|
const fontMetadata = APIv2[googleFontName.toLowerCase()];
|
||||||
|
if (!fontMetadata) {
|
||||||
|
throw new Error(`Font ${googleFontName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[font] = {
|
||||||
|
font: googleFontName,
|
||||||
|
unicodeRange: fontMetadata.unicodeRange,
|
||||||
|
variants: {
|
||||||
|
'400': {},
|
||||||
|
'700': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(output[font].variants).forEach((weight) => {
|
||||||
|
const variants = fontMetadata.variants[weight];
|
||||||
|
const normalVariant = variants.normal;
|
||||||
|
if (!normalVariant) {
|
||||||
|
throw new Error(`Font ${googleFontName} has no normal variant`);
|
||||||
|
}
|
||||||
|
|
||||||
|
output[font].variants[weight] = {};
|
||||||
|
Object.entries(normalVariant).forEach(([script, url]) => {
|
||||||
|
output[font].variants[weight][script] = url.url.woff;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeDataFile('fonts', JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We write both in dist and src as the build process might have happen already
|
||||||
|
* and tsc doesn't copy the files.
|
||||||
|
*/
|
||||||
|
async function writeDataFile(name, content) {
|
||||||
|
const srcData = path.resolve(__dirname, '../src/data');
|
||||||
|
const distData = path.resolve(__dirname, '../dist/data');
|
||||||
|
|
||||||
|
// Ensure the directories exists
|
||||||
|
await Promise.all([
|
||||||
|
fs.mkdir(srcData, { recursive: true }),
|
||||||
|
fs.mkdir(distData, { recursive: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fs.writeFile(path.resolve(srcData, `${name}.json`), content),
|
||||||
|
fs.writeFile(path.resolve(distData, `${name}.json`), content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`Error generating icons list: ${error}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@gitbook/fonts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gitbook/api": "catalog:"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"google-font-metadata": "^6.0.3",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"generate": "bun ./bin/generate.js",
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc -w",
|
||||||
|
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||||
|
"unit": "bun test"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Bun Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`getDefaultFont should return correct object for Latin text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should return correct object for Greek text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle mixed script text 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different font weights: regular 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different font weights: bold 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different fonts: inter 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Inter",
|
||||||
|
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
|
||||||
|
{
|
||||||
|
"font": "Roboto",
|
||||||
|
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { FontDefinitions } from './types';
|
||||||
|
|
||||||
|
import rawFonts from './data/fonts.json' with { type: 'json' };
|
||||||
|
|
||||||
|
export const fonts: FontDefinitions = rawFonts;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
import { getDefaultFont } from './getDefaultFont';
|
||||||
|
|
||||||
|
describe('getDefaultFont', () => {
|
||||||
|
it('should return null for invalid font', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: 'invalid-font' as CustomizationDefaultFont,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid weight', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 999 as any,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for text not supported by any script', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: '😀', // Emoji not supported by Inter
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct object for Latin text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello World',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct object for Cyrillic text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Привет мир',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct object for Greek text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Γεια σας',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed script text', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello Привет',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.font).toBe(CustomizationDefaultFont.Inter);
|
||||||
|
expect(result).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different font weights', () => {
|
||||||
|
const regular = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
const bold = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 700,
|
||||||
|
});
|
||||||
|
expect(regular).not.toBeNull();
|
||||||
|
expect(bold).not.toBeNull();
|
||||||
|
expect(regular).toMatchSnapshot('regular');
|
||||||
|
expect(bold).toMatchSnapshot('bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: '',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different fonts', () => {
|
||||||
|
const inter = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Inter,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
const roboto = getDefaultFont({
|
||||||
|
font: CustomizationDefaultFont.Roboto,
|
||||||
|
text: 'Hello',
|
||||||
|
weight: 400,
|
||||||
|
});
|
||||||
|
expect(inter).not.toBeNull();
|
||||||
|
expect(roboto).not.toBeNull();
|
||||||
|
expect(inter).toMatchSnapshot('inter');
|
||||||
|
expect(roboto).toMatchSnapshot('roboto');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
import { fonts } from './fonts';
|
||||||
|
import type { FontWeight } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL to load a font for a text.
|
||||||
|
*/
|
||||||
|
export function getDefaultFont(input: {
|
||||||
|
/**
|
||||||
|
* GitBook font to use.
|
||||||
|
*/
|
||||||
|
font: CustomizationDefaultFont;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text to display with the font.
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weight to use.
|
||||||
|
*/
|
||||||
|
weight: FontWeight;
|
||||||
|
}): { font: string; url: string } | null {
|
||||||
|
if (!input.text.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontDefinition = fonts[input.font];
|
||||||
|
if (!fontDefinition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = fontDefinition.variants[`${input.weight}`];
|
||||||
|
if (!variant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = getBestUnicodeRange(input.text, fontDefinition.unicodeRange);
|
||||||
|
if (!script) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return variant[script]
|
||||||
|
? {
|
||||||
|
font: input.font,
|
||||||
|
url: variant[script],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which named @font-face unicode-range covers
|
||||||
|
* the greatest share of the characters in `text`.
|
||||||
|
*
|
||||||
|
* @param text The text you want to inspect.
|
||||||
|
* @param ranges An object whose keys are range names and whose
|
||||||
|
* values are CSS-style comma-separated unicode-range
|
||||||
|
* declarations (e.g. "U+0370-03FF,U+1F00-1FFF").
|
||||||
|
* @returns The key of the best-matching range, or `null`
|
||||||
|
* when nothing matches at all.
|
||||||
|
*/
|
||||||
|
function getBestUnicodeRange(text: string, ranges: Record<string, string>): string | null {
|
||||||
|
// ---------- helper: parse "U+XXXX" or "U+XXXX-YYYY" ----------
|
||||||
|
const parseOne = (token: string): [number, number] | null => {
|
||||||
|
token = token.trim().toUpperCase();
|
||||||
|
if (!token.startsWith('U+')) return null;
|
||||||
|
|
||||||
|
const body = token.slice(2); // drop "U+"
|
||||||
|
const [startHex, endHex] = body.split('-');
|
||||||
|
const start = Number.parseInt(startHex, 16);
|
||||||
|
const end = endHex ? Number.parseInt(endHex, 16) : start;
|
||||||
|
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- helper: build lookup table ----------
|
||||||
|
const parsed: Record<string, [number, number][]> = {};
|
||||||
|
for (const [label, list] of Object.entries(ranges)) {
|
||||||
|
parsed[label] = list
|
||||||
|
.split(',')
|
||||||
|
.map(parseOne)
|
||||||
|
.filter((x): x is [number, number] => x !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tally code-point hits ----------
|
||||||
|
const hits: Record<string, number> = Object.fromEntries(Object.keys(parsed).map((k) => [k, 0]));
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; ) {
|
||||||
|
const cp = text.codePointAt(i)!;
|
||||||
|
i += cp > 0xffff ? 2 : 1; // advance by 1 UTF-16 code-unit (or 2 for surrogates)
|
||||||
|
|
||||||
|
for (const [label, rangesArr] of Object.entries(parsed)) {
|
||||||
|
if (rangesArr.some(([lo, hi]) => cp >= lo && cp <= hi)) {
|
||||||
|
hits[label]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- choose the "best" ----------
|
||||||
|
let winner: string | null = null;
|
||||||
|
let maxCount = 0;
|
||||||
|
|
||||||
|
for (const [label, count] of Object.entries(hits)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
winner = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxCount > 0 ? winner : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './getDefaultFont';
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CustomizationDefaultFont } from '@gitbook/api';
|
||||||
|
|
||||||
|
export type FontWeight = 400 | 700;
|
||||||
|
|
||||||
|
export type FontDefinition = {
|
||||||
|
font: string;
|
||||||
|
unicodeRange: {
|
||||||
|
[script: string]: string;
|
||||||
|
};
|
||||||
|
variants: {
|
||||||
|
[weight in string]: {
|
||||||
|
[script: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FontDefinitions = { [fontName in CustomizationDefaultFont]: FontDefinition };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "es2022",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"generate": {
|
||||||
|
"inputs": ["bin/**/*", "package.json"],
|
||||||
|
"outputs": ["src/data/*.json", "dist/data/*.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
# cloudflare
|
# cloudflare
|
||||||
.open-next
|
.open-next
|
||||||
|
.wrangler
|
||||||
|
|
||||||
# Symbolic links
|
# Symbolic links
|
||||||
public
|
public
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# 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
|
## 0.2.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference types="next/navigation-types/compat/navigation" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
|
import type { OpenNextConfig } from '@opennextjs/cloudflare';
|
||||||
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
|
|
||||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
|
||||||
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
|
||||||
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
|
||||||
|
|
||||||
export default defineCloudflareConfig({
|
export default {
|
||||||
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
|
default: {
|
||||||
tagCache: doShardedTagCache({
|
override: {
|
||||||
baseShardSize: 12,
|
wrapper: 'cloudflare-node',
|
||||||
regionalCache: true,
|
converter: 'edge',
|
||||||
shardReplication: {
|
proxyExternalRequest: 'fetch',
|
||||||
numberOfSoftReplicas: 2,
|
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||||
numberOfHardReplicas: 1,
|
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||||
|
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
queue: doQueue,
|
middleware: {
|
||||||
});
|
external: true,
|
||||||
|
override: {
|
||||||
|
wrapper: 'cloudflare-edge',
|
||||||
|
converter: 'edge',
|
||||||
|
proxyExternalRequest: 'fetch',
|
||||||
|
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||||
|
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||||
|
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dangerous: {
|
||||||
|
enableCacheInterception: true,
|
||||||
|
},
|
||||||
|
edgeExternals: ['node:crypto'],
|
||||||
|
} satisfies OpenNextConfig;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||||
|
|
||||||
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
|
|
||||||
|
// Only needed to run locally, in prod we'll use the one from do.js
|
||||||
|
export class R2WriteBuffer extends DurableObject {
|
||||||
|
writePromise;
|
||||||
|
|
||||||
|
async write(cacheKey, value) {
|
||||||
|
// We are already writing to this key
|
||||||
|
if (this.writePromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
|
||||||
|
this.ctx.waitUntil(
|
||||||
|
this.writePromise.finally(() => {
|
||||||
|
this.writePromise = undefined;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
return runWithCloudflareRequestContext(request, env, ctx, async () => {
|
||||||
|
// We can't move the handler import to the top level, otherwise the runtime will not be properly initialized
|
||||||
|
const { handler } = await import(
|
||||||
|
'../../.open-next/server-functions/default/handler.mjs'
|
||||||
|
);
|
||||||
|
|
||||||
|
// - `Request`s are handled by the Next server
|
||||||
|
return handler(request, env, ctx);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
{
|
||||||
|
"main": "default.js",
|
||||||
|
"name": "gitbook-open-v2-server",
|
||||||
|
"compatibility_date": "2025-04-14",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat",
|
||||||
|
"allow_importable_env",
|
||||||
|
"global_fetch_strictly_public"
|
||||||
|
],
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"dev": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "dev"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-server-dev"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["R2WriteBuffer"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "preview",
|
||||||
|
// Just as a test for the preview environment to check that everything works
|
||||||
|
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-server-preview"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer",
|
||||||
|
"script_name": "gitbook-open-v2-do-preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache",
|
||||||
|
"script_name": "gitbook-open-v2-do-preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler",
|
||||||
|
"script_name": "gitbook-open-v2-do-preview"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-server-staging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer",
|
||||||
|
"script_name": "gitbook-open-v2-do-staging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache",
|
||||||
|
"script_name": "gitbook-open-v2-do-staging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler",
|
||||||
|
"script_name": "gitbook-open-v2-do-staging"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-staging-tail"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-server-production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer",
|
||||||
|
"script_name": "gitbook-open-v2-do-production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache",
|
||||||
|
"script_name": "gitbook-open-v2-do-production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler",
|
||||||
|
"script_name": "gitbook-open-v2-do-production"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-prod-tail"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// This worker only purposes it to host the different DO that we will need in the other workers.
|
||||||
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
|
|
||||||
|
// `use cache` could cause multiple writes to the same key to happen concurrently, there is a limit of 1 write per key/second
|
||||||
|
// so we need to buffer writes to the R2 bucket to avoid hitting this limit.
|
||||||
|
export class R2WriteBuffer extends DurableObject {
|
||||||
|
writePromise;
|
||||||
|
|
||||||
|
async write(cacheKey, value) {
|
||||||
|
// We are already writing to this key
|
||||||
|
if (this.writePromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writePromise = this.env.NEXT_INC_CACHE_R2_BUCKET.put(cacheKey, value);
|
||||||
|
this.ctx.waitUntil(
|
||||||
|
this.writePromise.finally(() => {
|
||||||
|
this.writePromise = undefined;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
|
||||||
|
|
||||||
|
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch() {
|
||||||
|
// This worker does not handle any requests, it only provides Durable Objects
|
||||||
|
return new Response('This worker is not meant to handle requests directly', {
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"main": "do.js",
|
||||||
|
"name": "gitbook-open-v2-do",
|
||||||
|
"compatibility_date": "2025-04-14",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat",
|
||||||
|
"allow_importable_env",
|
||||||
|
"global_fetch_strictly_public"
|
||||||
|
],
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"preview": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "preview",
|
||||||
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "staging",
|
||||||
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-staging-tail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-staging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"vars": {
|
||||||
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||||
|
"STAGE": "production"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-prod-tail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache", "R2WriteBuffer"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||||
|
import { runWithCloudflareRequestContext } from '../../.open-next/cloudflare/init.js';
|
||||||
|
|
||||||
|
import { handler as middlewareHandler } from '../../.open-next/middleware/handler.mjs';
|
||||||
|
|
||||||
|
export { DOQueueHandler } from '../../.open-next/.build/durable-objects/queue.js';
|
||||||
|
|
||||||
|
export { DOShardedTagCache } from '../../.open-next/.build/durable-objects/sharded-tag-cache.js';
|
||||||
|
|
||||||
|
export default class extends WorkerEntrypoint {
|
||||||
|
async fetch(request) {
|
||||||
|
return runWithCloudflareRequestContext(request, this.env, this.ctx, async () => {
|
||||||
|
// - `Request`s are handled by the Next server
|
||||||
|
const reqOrResp = await middlewareHandler(request, this.env, this.ctx);
|
||||||
|
if (reqOrResp instanceof Response) {
|
||||||
|
return reqOrResp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.env.STAGE !== 'preview') {
|
||||||
|
// https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity
|
||||||
|
reqOrResp.headers.set(
|
||||||
|
'Cloudflare-Workers-Version-Overrides',
|
||||||
|
`gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"`
|
||||||
|
);
|
||||||
|
return this.env.DEFAULT_WORKER?.fetch(reqOrResp, {
|
||||||
|
cf: {
|
||||||
|
cacheEverything: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If we are in preview mode, we need to send the request to the preview URL
|
||||||
|
const modifiedUrl = new URL(reqOrResp.url);
|
||||||
|
modifiedUrl.hostname = this.env.PREVIEW_HOSTNAME;
|
||||||
|
const nextRequest = new Request(modifiedUrl, reqOrResp);
|
||||||
|
return fetch(nextRequest, {
|
||||||
|
cf: {
|
||||||
|
cacheEverything: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
{
|
||||||
|
"main": "middleware.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": {
|
||||||
|
"dev": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "dev",
|
||||||
|
"NEXT_PRIVATE_DEBUG_CACHE": "true"
|
||||||
|
},
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||||
|
"bucket_name": "gitbook-open-v2-cache-preview"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"binding": "WORKER_SELF_REFERENCE",
|
||||||
|
"service": "gitbook-open-v2-dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "DEFAULT_WORKER",
|
||||||
|
"service": "gitbook-open-v2-server-dev"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "preview",
|
||||||
|
"PREVIEW_HOSTNAME": "TO_REPLACE",
|
||||||
|
"WORKER_VERSION_ID": "TO_REPLACE"
|
||||||
|
},
|
||||||
|
"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": "DEFAULT_WORKER",
|
||||||
|
"service": "gitbook-open-v2-server-preview"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer",
|
||||||
|
"script_name": "gitbook-open-v2-do-preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache",
|
||||||
|
"script_name": "gitbook-open-v2-do-preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler",
|
||||||
|
"script_name": "gitbook-open-v2-do-preview"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"vars": {
|
||||||
|
"STAGE": "staging",
|
||||||
|
"WORKER_VERSION_ID": "TO_REPLACE"
|
||||||
|
},
|
||||||
|
"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": "DEFAULT_WORKER",
|
||||||
|
"service": "gitbook-open-v2-server-staging"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-staging-tail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer",
|
||||||
|
"script_name": "gitbook-open-v2-do-staging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache",
|
||||||
|
"script_name": "gitbook-open-v2-do-staging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler",
|
||||||
|
"script_name": "gitbook-open-v2-do-staging"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"vars": {
|
||||||
|
// This is a bit misleading, but it means that we can have 500 concurrent revalidations
|
||||||
|
// This means that we'll have up to 100 durable objects instance running at the same time
|
||||||
|
"MAX_REVALIDATE_CONCURRENCY": "100",
|
||||||
|
// Temporary variable to find the issue once deployed
|
||||||
|
// TODO: remove this once the issue is fixed
|
||||||
|
"DEBUG_CLOUDFLARE": "true",
|
||||||
|
"WORKER_VERSION_ID": "TO_REPLACE",
|
||||||
|
"STAGE": "production"
|
||||||
|
},
|
||||||
|
"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": "DEFAULT_WORKER",
|
||||||
|
"service": "gitbook-open-v2-server-production"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tail_consumers": [
|
||||||
|
{
|
||||||
|
"service": "gitbook-x-prod-tail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "WRITE_BUFFER",
|
||||||
|
"class_name": "R2WriteBuffer",
|
||||||
|
"script_name": "gitbook-open-v2-do-production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_TAG_CACHE_DO_SHARDED",
|
||||||
|
"class_name": "DOShardedTagCache",
|
||||||
|
"script_name": "gitbook-open-v2-do-production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NEXT_CACHE_DO_QUEUE",
|
||||||
|
"class_name": "DOQueueHandler",
|
||||||
|
"script_name": "gitbook-open-v2-do-production"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_sqlite_classes": ["DOQueueHandler", "DOShardedTagCache"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// In this script, we use the args from the cli to update the PREVIEW_URL vars in the wrangler config file for the middleware
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const wranglerConfigPath = path.join(__dirname, '../middlewareWrangler.jsonc');
|
||||||
|
|
||||||
|
const file = fs.readFileSync(wranglerConfigPath, 'utf-8');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
// The versionId is in the format xxx-xxx-xxx-xxx, we need the first part to reconstruct the preview URL
|
||||||
|
const versionId = args[0];
|
||||||
|
|
||||||
|
// The preview URL is in the format https://<versionId>-gitbook-open-v2-server-preview.gitbook.workers.dev
|
||||||
|
const previewHostname = `${versionId.split('-')[0]}-gitbook-open-v2-server-preview.gitbook.workers.dev`;
|
||||||
|
|
||||||
|
let updatedFile = file.replace(
|
||||||
|
/"PREVIEW_HOSTNAME": "TO_REPLACE"/,
|
||||||
|
`"PREVIEW_HOSTNAME": "${previewHostname}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedFile = updatedFile.replaceAll(
|
||||||
|
/"WORKER_VERSION_ID": "TO_REPLACE"/g,
|
||||||
|
`"WORKER_VERSION_ID": "${versionId}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(wranglerConfigPath, updatedFile);
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
|
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');
|
||||||
|
const result = (await localCacheEntry.json()) as WithLastModified<
|
||||||
|
CacheValue<CacheType>
|
||||||
|
>;
|
||||||
|
return this.returnNullOn404(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r2Object = await r2.get(cacheKey);
|
||||||
|
if (!r2Object) return null;
|
||||||
|
|
||||||
|
span.setAttribute('cacheHit', 'r2');
|
||||||
|
return this.returnNullOn404({
|
||||||
|
value: await r2Object.json(),
|
||||||
|
lastModified: r2Object.uploaded.getTime(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get from cache', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: This is a workaround to handle 404 responses in the cache.
|
||||||
|
// It should be handled by OpenNext cache interception directly. This should be removed once OpenNext cache interception is fixed.
|
||||||
|
returnNullOn404<CacheType extends CacheEntryType = 'cache'>(
|
||||||
|
cacheEntry: WithLastModified<CacheValue<CacheType>> | null
|
||||||
|
): WithLastModified<CacheValue<CacheType>> | null {
|
||||||
|
if (!cacheEntry?.value) return null;
|
||||||
|
if ('meta' in cacheEntry.value && cacheEntry.value.meta?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cacheEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 localCache = await this.getCacheInstance();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.writeToR2(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 writeToR2(key: string, value: string): Promise<void> {
|
||||||
|
const env = getCloudflareContext().env as {
|
||||||
|
WRITE_BUFFER: DurableObjectNamespace<
|
||||||
|
Rpc.DurableObjectBranded & {
|
||||||
|
write: (key: string, value: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
const id = env.WRITE_BUFFER.idFromName(key);
|
||||||
|
|
||||||
|
// A stub is a client used to invoke methods on the Durable Object
|
||||||
|
const stub = env.WRITE_BUFFER.get(id);
|
||||||
|
|
||||||
|
await stub.write(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { trace } from '@/lib/tracing';
|
||||||
|
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||||
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
|
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GitbookISRQueue',
|
||||||
|
send: async (msg) => {
|
||||||
|
return trace({ operation: 'gitbookISRQueueSend', name: msg.MessageBody.url }, async () => {
|
||||||
|
const { ctx } = getCloudflareContext();
|
||||||
|
ctx.waitUntil(doQueue.send(msg));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} satisfies Queue;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Queue } from '@opennextjs/aws/types/overrides.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GitbookISRQueue',
|
||||||
|
send: async (msg) => {
|
||||||
|
// We should never reach this point in the server. If that's the case, we should log it.
|
||||||
|
console.warn('GitbookISRQueue: send called on server side, this should not happen.', msg);
|
||||||
|
},
|
||||||
|
} satisfies Queue;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { trace } from '@/lib/tracing';
|
||||||
|
import type { NextModeTagCache } from '@opennextjs/aws/types/overrides.js';
|
||||||
|
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
|
||||||
|
import { softTagFilter } from '@opennextjs/cloudflare/overrides/tag-cache/tag-cache-filter';
|
||||||
|
|
||||||
|
const originalTagCache = doShardedTagCache({
|
||||||
|
baseShardSize: 12,
|
||||||
|
regionalCache: true,
|
||||||
|
// We can probably increase this value even further
|
||||||
|
regionalCacheTtlSec: 60,
|
||||||
|
shardReplication: {
|
||||||
|
numberOfSoftReplicas: 2,
|
||||||
|
numberOfHardReplicas: 1,
|
||||||
|
regionalReplication: {
|
||||||
|
defaultRegion: 'enam',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GitbookTagCache',
|
||||||
|
mode: 'nextMode',
|
||||||
|
getLastRevalidated: async (tags: string[]) => {
|
||||||
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
|
if (tagsToCheck.length === 0) {
|
||||||
|
// If we reach here, it probably means that there is an issue that we'll need to address.
|
||||||
|
console.warn(
|
||||||
|
'getLastRevalidated - No valid tags to check for last revalidation, original tags:',
|
||||||
|
tags
|
||||||
|
);
|
||||||
|
return 0; // If no tags to check, return 0
|
||||||
|
}
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'gitbookTagCacheGetLastRevalidated',
|
||||||
|
name: tagsToCheck.join(', '),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
return await originalTagCache.getLastRevalidated(tagsToCheck);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hasBeenRevalidated: async (tags: string[], lastModified?: number) => {
|
||||||
|
const tagsToCheck = tags.filter(softTagFilter);
|
||||||
|
if (tagsToCheck.length === 0) {
|
||||||
|
// If we reach here, it probably means that there is an issue that we'll need to address.
|
||||||
|
console.warn(
|
||||||
|
'hasBeenRevalidated - No valid tags to check for revalidation, original tags:',
|
||||||
|
tags
|
||||||
|
);
|
||||||
|
return false; // If no tags to check, return false
|
||||||
|
}
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'gitbookTagCacheHasBeenRevalidated',
|
||||||
|
name: tagsToCheck.join(', '),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await originalTagCache.hasBeenRevalidated(tagsToCheck, lastModified);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
writeTags: async (tags: string[]) => {
|
||||||
|
return trace(
|
||||||
|
{
|
||||||
|
operation: 'gitbookTagCacheWriteTags',
|
||||||
|
name: tags.join(', '),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const tagsToWrite = tags.filter(softTagFilter);
|
||||||
|
if (tagsToWrite.length === 0) {
|
||||||
|
console.warn('writeTags - No valid tags to write');
|
||||||
|
return; // If no tags to write, exit early
|
||||||
|
}
|
||||||
|
// Write only the filtered tags
|
||||||
|
await originalTagCache.writeTags(tagsToWrite);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies NextModeTagCache;
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook-v2",
|
"name": "gitbook-v2",
|
||||||
"version": "0.2.5",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "canary",
|
"@gitbook/api": "catalog:",
|
||||||
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
|
"@opennextjs/cloudflare": "1.2.1",
|
||||||
|
"@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": "*",
|
"rison": "^0.1.1",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"warn-once": "^0.1.1",
|
"warn-once": "^0.1.1",
|
||||||
"rison": "^0.1.1",
|
"object-identity": "^0.1.2"
|
||||||
"jwt-decode": "^4.0.0",
|
|
||||||
"assert-never": "^1.2.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gitbook": "*",
|
"gitbook": "*",
|
||||||
"@opennextjs/cloudflare": "^1.0.0-beta.3",
|
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"postcss": "^8"
|
"postcss": "^8"
|
||||||
@@ -29,7 +30,9 @@
|
|||||||
"build:v2": "next build",
|
"build:v2": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"build:v2:cloudflare": "opennextjs-cloudflare build",
|
"build:v2:cloudflare": "opennextjs-cloudflare build",
|
||||||
"dev:v2:cloudflare": "wrangler dev --port 8771",
|
"dev:v2:cloudflare": "wrangler dev --port 8771 --env preview",
|
||||||
|
"dev:v2:cf:middleware": "wrangler dev --port 8771 --inspector-port 9230 --env dev --config ./openNext/customWorkers/middlewareWrangler.jsonc",
|
||||||
|
"dev:v2:cf:server": "wrangler dev --port 8772 --env dev --config ./openNext/customWorkers/defaultWrangler.jsonc",
|
||||||
"unit": "bun test",
|
"unit": "bun test",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import NextError from 'next/error';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
}) {
|
||||||
|
console.error('Global error:', error);
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<NextError statusCode={undefined as any} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-5
@@ -3,7 +3,7 @@ import {
|
|||||||
generateSitePageMetadata,
|
generateSitePageMetadata,
|
||||||
generateSitePageViewport,
|
generateSitePageViewport,
|
||||||
} from '@/components/SitePage';
|
} from '@/components/SitePage';
|
||||||
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@v2/app/utils';
|
import { type RouteParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
@@ -14,9 +14,8 @@ type PageProps = {
|
|||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getDynamicSiteContext(params);
|
const { context } = await getDynamicSiteContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
|
||||||
|
|
||||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
return <SitePage context={context} pageParams={params} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||||
@@ -27,10 +26,9 @@ export async function generateViewport(props: PageProps): Promise<Viewport> {
|
|||||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getDynamicSiteContext(params);
|
const { context } = await getDynamicSiteContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
|
||||||
|
|
||||||
return generateSitePageMetadata({
|
return generateSitePageMetadata({
|
||||||
context,
|
context,
|
||||||
pageParams: { pathname },
|
pageParams: params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-9
@@ -1,9 +1,5 @@
|
|||||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||||
import {
|
import { SiteLayout, generateSiteLayoutViewport } from '@/components/SiteLayout';
|
||||||
SiteLayout,
|
|
||||||
generateSiteLayoutMetadata,
|
|
||||||
generateSiteLayoutViewport,
|
|
||||||
} from '@/components/SiteLayout';
|
|
||||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||||
import { getThemeFromMiddleware } from '@v2/lib/middleware';
|
import { getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||||
@@ -38,7 +34,7 @@ export async function generateViewport({ params }: SiteDynamicLayoutProps) {
|
|||||||
return generateSiteLayoutViewport(context);
|
return generateSiteLayoutViewport(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
|
// export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
|
||||||
const { context } = await getDynamicSiteContext(await params);
|
// const { context } = await getDynamicSiteContext(await params);
|
||||||
return generateSiteLayoutMetadata(context);
|
// return generateSiteLayoutMetadata(context);
|
||||||
}
|
// }
|
||||||
|
|||||||
+9
-8
@@ -3,7 +3,8 @@ import {
|
|||||||
generateSitePageMetadata,
|
generateSitePageMetadata,
|
||||||
generateSitePageViewport,
|
generateSitePageViewport,
|
||||||
} from '@/components/SitePage';
|
} from '@/components/SitePage';
|
||||||
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
|
import type { RouteParams } from '@v2/app/utils';
|
||||||
|
import { getPrefetchedDataFromLayoutParams } from '@v2/lib/data/prefetch';
|
||||||
|
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
|
||||||
@@ -15,24 +16,24 @@ type PageProps = {
|
|||||||
|
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getStaticSiteContext(params);
|
const { staticSiteContext } = getPrefetchedDataFromLayoutParams(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const { context } = await staticSiteContext;
|
||||||
|
|
||||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
return <SitePage context={context} pageParams={params} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||||
const { context } = await getStaticSiteContext(await props.params);
|
const params = await props.params;
|
||||||
|
const { context } = await getPrefetchedDataFromLayoutParams(params).staticSiteContext;
|
||||||
return generateSitePageViewport(context);
|
return generateSitePageViewport(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getStaticSiteContext(params);
|
const { context } = await getPrefetchedDataFromLayoutParams(params).staticSiteContext;
|
||||||
const pathname = getPagePathFromParams(params);
|
|
||||||
|
|
||||||
return generateSitePageMetadata({
|
return generateSitePageMetadata({
|
||||||
context,
|
context,
|
||||||
pageParams: { pathname },
|
pageParams: params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
generateSiteLayoutViewport,
|
generateSiteLayoutViewport,
|
||||||
} from '@/components/SiteLayout';
|
} from '@/components/SiteLayout';
|
||||||
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
import { getPrefetchedDataFromLayoutParams } from '@v2/lib/data/prefetch';
|
||||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||||
|
|
||||||
interface SiteStaticLayoutProps {
|
interface SiteStaticLayoutProps {
|
||||||
@@ -36,6 +37,6 @@ export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||||
const { context } = await getStaticSiteContext(await params);
|
const prefetchedData = getPrefetchedDataFromLayoutParams(await params);
|
||||||
return generateSiteLayoutMetadata(context);
|
return generateSiteLayoutMetadata(prefetchedData);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
import { serveLLMsFullTxt } from '@/routes/llms-full';
|
||||||
|
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<RouteLayoutParams> }
|
||||||
|
) {
|
||||||
|
const { context } = await getStaticSiteContext(await params);
|
||||||
|
return serveLLMsFullTxt(context);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||||
|
import { getDynamicCustomizationSettings } from '@/lib/customization';
|
||||||
import type { SiteAPIToken } from '@gitbook/api';
|
import type { SiteAPIToken } from '@gitbook/api';
|
||||||
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
import {
|
||||||
|
type GitBookSiteContext,
|
||||||
|
type SiteURLData,
|
||||||
|
fetchSiteContextByURLLookup,
|
||||||
|
getBaseContext,
|
||||||
|
} from '@v2/lib/context';
|
||||||
|
import { getResizedImageURL } from '@v2/lib/images';
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { jwtDecode } from 'jwt-decode';
|
||||||
import { forbidden } from 'next/navigation';
|
import { forbidden } from 'next/navigation';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
@@ -67,6 +74,8 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
|
|||||||
siteURLData
|
siteURLData
|
||||||
);
|
);
|
||||||
|
|
||||||
|
context.customization = await getDynamicCustomizationSettings(context.customization);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
|
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
|
||||||
@@ -102,3 +111,15 @@ function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
|||||||
const decoded = decodeURIComponent(params.siteData);
|
const decoded = decodeURIComponent(params.siteData);
|
||||||
return rison.decode(decoded);
|
return rison.decode(decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getIcon = async (context: GitBookSiteContext, theme: 'light' | 'dark') => {
|
||||||
|
const { linker, imageResizer, customization } = context;
|
||||||
|
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||||
|
const faviconSize = 48;
|
||||||
|
return customIcon?.[theme]
|
||||||
|
? await getResizedImageURL(imageResizer, customIcon[theme], {
|
||||||
|
width: faviconSize,
|
||||||
|
height: faviconSize,
|
||||||
|
})
|
||||||
|
: linker.toAbsoluteURL(linker.toPathInSpace('~gitbook/icon?size=small&theme=light'));
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { withStableRef } from './cache';
|
||||||
|
|
||||||
|
describe('withStableRef', () => {
|
||||||
|
it('should return primitive values as is', () => {
|
||||||
|
const toStableRef = withStableRef();
|
||||||
|
|
||||||
|
expect(toStableRef(42)).toBe(42);
|
||||||
|
expect(toStableRef('hello')).toBe('hello');
|
||||||
|
expect(toStableRef(true)).toBe(true);
|
||||||
|
expect(toStableRef(null)).toBe(null);
|
||||||
|
expect(toStableRef(undefined)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same reference for identical objects', () => {
|
||||||
|
const toStableRef = withStableRef();
|
||||||
|
|
||||||
|
const obj1 = { a: 1, b: 2 };
|
||||||
|
const obj2 = { a: 1, b: 2 };
|
||||||
|
|
||||||
|
const ref1 = toStableRef(obj1);
|
||||||
|
const ref2 = toStableRef(obj2);
|
||||||
|
|
||||||
|
expect(ref1).toBe(ref2);
|
||||||
|
expect(ref1).toBe(obj1);
|
||||||
|
expect(ref1).not.toBe(obj2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same reference for identical arrays', () => {
|
||||||
|
const toStableRef = withStableRef();
|
||||||
|
|
||||||
|
const arr1 = [1, 2, 3];
|
||||||
|
const arr2 = [1, 2, 3];
|
||||||
|
|
||||||
|
const ref1 = toStableRef(arr1);
|
||||||
|
const ref2 = toStableRef(arr2);
|
||||||
|
|
||||||
|
expect(ref1).toBe(ref2);
|
||||||
|
expect(ref1).toBe(arr1);
|
||||||
|
expect(ref1).not.toBe(arr2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same reference for identical nested objects', () => {
|
||||||
|
const toStableRef = withStableRef();
|
||||||
|
|
||||||
|
const obj1 = { a: { b: 1 }, c: [2, 3] };
|
||||||
|
const obj2 = { a: { b: 1 }, c: [2, 3] };
|
||||||
|
|
||||||
|
const ref1 = toStableRef(obj1);
|
||||||
|
const ref2 = toStableRef(obj2);
|
||||||
|
|
||||||
|
expect(ref1).toBe(ref2);
|
||||||
|
expect(ref1).toBe(obj1);
|
||||||
|
expect(ref1).not.toBe(obj2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return different references for different objects', () => {
|
||||||
|
const toStableRef = withStableRef();
|
||||||
|
|
||||||
|
const obj1 = { a: 1 };
|
||||||
|
const obj2 = { a: 2 };
|
||||||
|
|
||||||
|
const ref1 = toStableRef(obj1);
|
||||||
|
const ref2 = toStableRef(obj2);
|
||||||
|
|
||||||
|
expect(ref1).not.toBe(ref2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain reference stability across multiple calls', () => {
|
||||||
|
const toStableRef = withStableRef();
|
||||||
|
|
||||||
|
const obj = { a: 1 };
|
||||||
|
const ref1 = toStableRef(obj);
|
||||||
|
const ref2 = toStableRef(obj);
|
||||||
|
const ref3 = toStableRef(obj);
|
||||||
|
|
||||||
|
expect(ref1).toBe(ref2);
|
||||||
|
expect(ref2).toBe(ref3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { identify } from 'object-identity';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equivalent to `React.cache` but with support for non-primitive arguments.
|
||||||
|
* As `React.cache` only uses `Object.is` to compare arguments, it will not work with non-primitive arguments.
|
||||||
|
*/
|
||||||
|
export function cache<Args extends any[], Return>(fn: (...args: Args) => Return) {
|
||||||
|
const cached = React.cache(fn);
|
||||||
|
|
||||||
|
return (...args: Args) => {
|
||||||
|
const toStableRef = getWithStableRef();
|
||||||
|
const stableArgs = args.map((value) => {
|
||||||
|
return toStableRef(value);
|
||||||
|
}) as Args;
|
||||||
|
return cached(...stableArgs);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To ensure memory is garbage collected between each request, we use a per-request cache to store the ref maps.
|
||||||
|
*/
|
||||||
|
const getWithStableRef = React.cache(withStableRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a function that converts a value to a stable reference.
|
||||||
|
*/
|
||||||
|
export function withStableRef(): <T>(value: T) => T {
|
||||||
|
const reverseIndex = new WeakMap<object, string>();
|
||||||
|
const refIndex = new Map<string, object>();
|
||||||
|
|
||||||
|
return <T>(value: T) => {
|
||||||
|
if (isPrimitive(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectValue = value as object;
|
||||||
|
const index = reverseIndex.get(objectValue);
|
||||||
|
if (index !== undefined) {
|
||||||
|
return refIndex.get(index) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = identify(objectValue);
|
||||||
|
reverseIndex.set(objectValue, hash);
|
||||||
|
|
||||||
|
const existing = refIndex.get(hash);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
return existing as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// first time we've seen this shape
|
||||||
|
refIndex.set(hash, objectValue);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimitive(value: any): boolean {
|
||||||
|
return value === null || typeof value !== 'object';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -164,7 +165,7 @@ export function getBaseContext(input: {
|
|||||||
// Create link in the same format for links to other sites/sections.
|
// Create link in the same format for links to other sites/sections.
|
||||||
linker.toLinkForContent = (rawURL: string) => {
|
linker.toLinkForContent = (rawURL: string) => {
|
||||||
const urlObject = new URL(rawURL);
|
const urlObject = new URL(rawURL);
|
||||||
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
|
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}${urlObject.hash}`;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
if (!siteSpace) {
|
||||||
throw new Error('Site space not found');
|
throw new Error(
|
||||||
|
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteSpaces =
|
return { siteSpaces, siteSpace };
|
||||||
siteStructure.type === 'siteSpaces'
|
}
|
||||||
? siteStructure.structure
|
|
||||||
: (sections?.current.siteSpaces ?? []);
|
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
@@ -11,15 +11,3 @@ export function getCloudflareContext() {
|
|||||||
|
|
||||||
return getCloudflareContextOpenNext();
|
return getCloudflareContextOpenNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return an object representing the current request.
|
|
||||||
*/
|
|
||||||
export function getCloudflareRequestGlobal() {
|
|
||||||
const context = getCloudflareContext();
|
|
||||||
if (!context) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.cf;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * from './api';
|
export * from './api';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './pages';
|
|
||||||
export * from './urls';
|
export * from './urls';
|
||||||
export * from './errors';
|
export * from './errors';
|
||||||
export * from './lookup';
|
export * from './lookup';
|
||||||
export * from './proxy';
|
|
||||||
export * from './visitor';
|
export * from './visitor';
|
||||||
|
export * from './pages';
|
||||||
|
|||||||
@@ -1,52 +1,46 @@
|
|||||||
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 { 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;
|
||||||
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
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 lookupPublishedContentByUrl(
|
||||||
|
input: LookupPublishedContentByUrlInput
|
||||||
|
): 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 api = apiClient({ apiToken: input.apiToken });
|
||||||
|
|
||||||
const callResult = await trace(
|
const callResult = await trace(
|
||||||
{
|
{
|
||||||
operation: 'getPublishedContentByURL',
|
operation: 'resolvePublishedContentByUrl',
|
||||||
name: alternative.url,
|
name: alternative.url,
|
||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
tryCatch(
|
tryCatch(
|
||||||
api.urls.getPublishedContentByUrl(
|
api.urls.resolvePublishedContentByUrl(
|
||||||
{
|
{
|
||||||
url: alternative.url,
|
url: alternative.url,
|
||||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
|
||||||
redirectOnError: input.redirectOnError,
|
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 }
|
||||||
signal,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, expect, it, mock } from 'bun:test';
|
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
||||||
import { withCacheKey, withoutConcurrentExecution } from './memoize';
|
|
||||||
|
|
||||||
describe('withoutConcurrentExecution', () => {
|
|
||||||
it('should memoize the function based on the cache key', async () => {
|
|
||||||
const fn = mock(async (_cacheKey: string, a: number, b: number) => a + b);
|
|
||||||
const memoized = withoutConcurrentExecution(() => null, fn);
|
|
||||||
|
|
||||||
const p1 = memoized('c1', 1, 2);
|
|
||||||
const p2 = memoized('c1', 1, 2);
|
|
||||||
const p3 = memoized('c3', 2, 3);
|
|
||||||
|
|
||||||
expect(await p1).toBe(await p2);
|
|
||||||
expect(await p1).not.toBe(await p3);
|
|
||||||
expect(fn.mock.calls.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support caching per request', async () => {
|
|
||||||
const fn = mock(async () => Math.random());
|
|
||||||
|
|
||||||
const request1 = { id: 'request1' };
|
|
||||||
const request2 = { id: 'request2' };
|
|
||||||
|
|
||||||
const requestContext = new AsyncLocalStorage<{ id: string }>();
|
|
||||||
|
|
||||||
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn);
|
|
||||||
|
|
||||||
// Both in the same request
|
|
||||||
const promise1 = requestContext.run(request1, () => memoized('c1'));
|
|
||||||
const promise2 = requestContext.run(request1, () => memoized('c1'));
|
|
||||||
|
|
||||||
// In a different request
|
|
||||||
const promise3 = requestContext.run(request2, () => memoized('c1'));
|
|
||||||
|
|
||||||
expect(await promise1).toBe(await promise2);
|
|
||||||
expect(await promise1).not.toBe(await promise3);
|
|
||||||
expect(fn.mock.calls.length).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('withCacheKey', () => {
|
|
||||||
it('should wrap the function by passing the cache key', async () => {
|
|
||||||
const fn = mock(
|
|
||||||
async (cacheKey: string, arg: { a: number; b: number }, c: number) =>
|
|
||||||
`${cacheKey}, result=${arg.a + arg.b + c}`
|
|
||||||
);
|
|
||||||
const memoized = withCacheKey(fn);
|
|
||||||
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
|
|
||||||
expect(fn.mock.calls.length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a function by passing it a cache key that is computed from the function arguments.
|
|
||||||
*/
|
|
||||||
export function withCacheKey<ArgsType extends any[], ReturnType>(
|
|
||||||
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
|
|
||||||
): (...args: ArgsType) => Promise<ReturnType> {
|
|
||||||
return (...args: ArgsType) => {
|
|
||||||
const cacheKey = getCacheKey(args);
|
|
||||||
return wrapped(cacheKey, ...args);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute a cache key from the function arguments.
|
|
||||||
*/
|
|
||||||
function getCacheKey(args: any[]) {
|
|
||||||
return JSON.stringify(deepSortValue(args));
|
|
||||||
}
|
|
||||||
|
|
||||||
function deepSortValue(value: unknown): unknown {
|
|
||||||
if (
|
|
||||||
typeof value === 'string' ||
|
|
||||||
typeof value === 'number' ||
|
|
||||||
typeof value === 'boolean' ||
|
|
||||||
value === null ||
|
|
||||||
value === undefined
|
|
||||||
) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map(deepSortValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
return Object.entries(value)
|
|
||||||
.map(([key, subValue]) => {
|
|
||||||
return [key, deepSortValue(subValue)] as const;
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
return a[0].localeCompare(b[0]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
import type { JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
|
import { waitUntil } from '@/lib/waitUntil';
|
||||||
|
import type { JSONDocument, RevisionPageDocument } from '@gitbook/api';
|
||||||
|
import type { GitBookSiteContext, GitBookSpaceContext } from '../context';
|
||||||
import { getDataOrNull } from './errors';
|
import { getDataOrNull } from './errors';
|
||||||
import type { GitBookDataFetcher } from './types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the document for a page.
|
* Get the document for a page.
|
||||||
*/
|
*/
|
||||||
export async function getPageDocument(
|
export async function getPageDocument(
|
||||||
dataFetcher: GitBookDataFetcher,
|
context: GitBookSpaceContext | GitBookSiteContext,
|
||||||
space: Space,
|
|
||||||
page: RevisionPageDocument
|
page: RevisionPageDocument
|
||||||
): Promise<JSONDocument | null> {
|
): Promise<JSONDocument | null> {
|
||||||
|
const { dataFetcher, space } = context;
|
||||||
|
|
||||||
|
if (
|
||||||
|
'site' in context &&
|
||||||
|
(context.site.id === 'site_JOVzv' || context.site.id === 'site_IxAYj')
|
||||||
|
) {
|
||||||
|
return getDataOrNull(
|
||||||
|
dataFetcher.getRevisionPageDocument({
|
||||||
|
spaceId: space.id,
|
||||||
|
revisionId: space.revision,
|
||||||
|
pageId: page.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (page.documentId) {
|
if (page.documentId) {
|
||||||
return getDataOrNull(
|
return getDataOrNull(
|
||||||
dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId })
|
dataFetcher.getDocument({ spaceId: space.id, documentId: page.documentId })
|
||||||
@@ -26,5 +41,33 @@ export async function getPageDocument(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-fetch the document to start filling the cache before we migrate to this API.
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// Disable for now to investigate side-effects
|
||||||
|
if (isInPercentRollout(space.id, 10) || process.env.VERCEL_ENV === 'preview') {
|
||||||
|
await waitUntil(
|
||||||
|
getDataOrNull(
|
||||||
|
dataFetcher.getRevisionPageDocument({
|
||||||
|
spaceId: space.id,
|
||||||
|
revisionId: space.revision,
|
||||||
|
pageId: page.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInPercentRollout(value: string, rollout: number) {
|
||||||
|
return getRandomPercent(value) < rollout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomPercent(value: string) {
|
||||||
|
const hash = value.split('').reduce((acc, char) => {
|
||||||
|
return acc + char.charCodeAt(0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return hash % 100;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { type PagePathParams, fetchPageData } from '@/components/SitePage';
|
||||||
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
|
import type { AncestorRevisionPage } from '@/lib/pages';
|
||||||
|
import {
|
||||||
|
type ResolveContentRefOptions,
|
||||||
|
type ResolvedContentRef,
|
||||||
|
resolveContentRef,
|
||||||
|
} from '@/lib/references';
|
||||||
|
import type { ContentRef, JSONDocument, RevisionPageDocument } from '@gitbook/api';
|
||||||
|
import {
|
||||||
|
type RouteLayoutParams,
|
||||||
|
type RouteParams,
|
||||||
|
getIcon,
|
||||||
|
getPagePathFromParams,
|
||||||
|
getStaticSiteContext,
|
||||||
|
} from '@v2/app/utils';
|
||||||
|
import { identify } from 'object-identity';
|
||||||
|
import { cache } from '../cache';
|
||||||
|
import type { GitBookSiteContext } from '../context';
|
||||||
|
import { getPageDocument } from './pages';
|
||||||
|
|
||||||
|
export interface PrefetchedLayoutData {
|
||||||
|
staticSiteContext: Promise<{
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
visitorAuthClaims: VisitorAuthClaims;
|
||||||
|
}>;
|
||||||
|
icons: Promise<
|
||||||
|
{
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
media: string;
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrefetchedPageData {
|
||||||
|
pageData: Promise<{
|
||||||
|
context: GitBookSiteContext & {
|
||||||
|
page?: RevisionPageDocument;
|
||||||
|
};
|
||||||
|
pageTarget?: {
|
||||||
|
page: RevisionPageDocument;
|
||||||
|
ancestors: AncestorRevisionPage[];
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
document: Promise<JSONDocument | null>;
|
||||||
|
getPrefetchedRef: (
|
||||||
|
ref?: ContentRef,
|
||||||
|
options?: ResolveContentRefOptions
|
||||||
|
) => Promise<ResolvedContentRef | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedInitialDate = cache(() => Date.now());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
|
||||||
|
*/
|
||||||
|
export async function getPageDataWithFallback(args: {
|
||||||
|
context: GitBookSiteContext;
|
||||||
|
pagePathParams: PagePathParams;
|
||||||
|
}) {
|
||||||
|
const { context: baseContext, pagePathParams } = args;
|
||||||
|
const { context, pageTarget } = await fetchPageData(baseContext, pagePathParams);
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
...context,
|
||||||
|
page: pageTarget?.page,
|
||||||
|
},
|
||||||
|
pageTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIcons(context: GitBookSiteContext): Promise<
|
||||||
|
{
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
media: string;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
return Promise.all([getIcon(context, 'light'), getIcon(context, 'dark')]).then((urls) => [
|
||||||
|
{
|
||||||
|
url: urls[0],
|
||||||
|
type: 'image/png',
|
||||||
|
media: '(prefers-color-scheme: light)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: urls[1],
|
||||||
|
type: 'image/png',
|
||||||
|
media: '(prefers-color-scheme: dark)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPrefetchedDataFromLayoutParams = cache(
|
||||||
|
(params: RouteLayoutParams): PrefetchedLayoutData => {
|
||||||
|
const startingDate = cachedInitialDate();
|
||||||
|
const staticSiteContext = getStaticSiteContext(params).finally(() => {
|
||||||
|
console.log(`Finished fetching static site context in ${Date.now() - startingDate}ms`);
|
||||||
|
});
|
||||||
|
const icons = staticSiteContext
|
||||||
|
.then(({ context }) => getIcons(context))
|
||||||
|
.finally(() => {
|
||||||
|
console.log(`Finished fetching icons in ${Date.now() - startingDate}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
staticSiteContext,
|
||||||
|
icons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const prefetchedDocumentRef = (
|
||||||
|
document: JSONDocument | null,
|
||||||
|
context: GitBookSiteContext
|
||||||
|
) => {
|
||||||
|
const fetched = new Map<string, Promise<ResolvedContentRef | null>>();
|
||||||
|
if (!document) return fetched;
|
||||||
|
|
||||||
|
const traverseNodes = (nodes: any[]): void => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
// We try prefetching as many references as possible.
|
||||||
|
if (node.data?.ref) {
|
||||||
|
fetched.set(identify(node.data.ref), resolveContentRef(node.data.ref, context));
|
||||||
|
}
|
||||||
|
// Handle prefetching of references for cards
|
||||||
|
if (node.data?.view && node.data.view.type === 'cards') {
|
||||||
|
const view = node.data.view;
|
||||||
|
const records = Object.entries(node.data.records || {});
|
||||||
|
|
||||||
|
records.forEach(async (record: [string, any]) => {
|
||||||
|
const coverFile = view.coverDefinition
|
||||||
|
? record[1].values[view.coverDefinition]?.[0]
|
||||||
|
: null;
|
||||||
|
const targetRef = view.targetDefinition
|
||||||
|
? (record[1].values[view.targetDefinition] as ContentRef)
|
||||||
|
: null;
|
||||||
|
if (targetRef) {
|
||||||
|
fetched.set(identify(targetRef), resolveContentRef(targetRef, context));
|
||||||
|
}
|
||||||
|
if (coverFile) {
|
||||||
|
const fileRef = {
|
||||||
|
kind: 'file' as const,
|
||||||
|
file: coverFile,
|
||||||
|
};
|
||||||
|
fetched.set(identify(fileRef), resolveContentRef(fileRef, context));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.nodes && Array.isArray(node.nodes)) {
|
||||||
|
traverseNodes(node.nodes);
|
||||||
|
}
|
||||||
|
if (node.fragments && Array.isArray(node.fragments)) {
|
||||||
|
traverseNodes(node.fragments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.nodes && Array.isArray(document.nodes)) {
|
||||||
|
traverseNodes(document.nodes);
|
||||||
|
}
|
||||||
|
return fetched;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrefetchedDataFromPageParams = cache((params: RouteParams): PrefetchedPageData => {
|
||||||
|
const startingDate = cachedInitialDate();
|
||||||
|
const { staticSiteContext } = getPrefetchedDataFromLayoutParams(params);
|
||||||
|
const pathname = getPagePathFromParams(params);
|
||||||
|
const pageData = staticSiteContext
|
||||||
|
.then(({ context }) =>
|
||||||
|
getPageDataWithFallback({
|
||||||
|
context,
|
||||||
|
pagePathParams: {
|
||||||
|
pathname,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
console.log(`Finished fetching page data in ${Date.now() - startingDate}ms`);
|
||||||
|
});
|
||||||
|
const document = pageData
|
||||||
|
.then(({ context, pageTarget }) => {
|
||||||
|
if (!pageTarget?.page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getPageDocument(context, pageTarget?.page);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
console.log(`Finished fetching document in ${Date.now() - startingDate}ms`);
|
||||||
|
});
|
||||||
|
const prefetchedRef = Promise.all([staticSiteContext, document])
|
||||||
|
.then(([{ context }, document]) => {
|
||||||
|
// Prefetch the references in the document
|
||||||
|
return prefetchedDocumentRef(document, context);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
console.log(`Finished prefetching references in ${Date.now() - startingDate}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getContentRef = async (
|
||||||
|
ref?: ContentRef,
|
||||||
|
options?: ResolveContentRefOptions
|
||||||
|
): Promise<ResolvedContentRef | null> => {
|
||||||
|
if (!ref) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options) {
|
||||||
|
const { context } = await staticSiteContext;
|
||||||
|
return resolveContentRef(ref, context, options);
|
||||||
|
}
|
||||||
|
return prefetchedRef.then((prefetched) => prefetched.get(identify(ref)) ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageData,
|
||||||
|
document,
|
||||||
|
getPrefetchedRef: getContentRef,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -106,6 +106,15 @@ export interface GitBookDataFetcher {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
}): Promise<DataFetcherResponse<string>>;
|
}): Promise<DataFetcherResponse<string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the document of a page by its path.
|
||||||
|
*/
|
||||||
|
getRevisionPageDocument(params: {
|
||||||
|
spaceId: string;
|
||||||
|
revisionId: string;
|
||||||
|
pageId: string;
|
||||||
|
}): Promise<DataFetcherResponse<api.JSONDocument>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a document by its space ID and document ID.
|
* Get a document by its space ID and document ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||||
import type { PublishedSiteContent } from '@gitbook/api';
|
import type { PublishedSiteContent } from '@gitbook/api';
|
||||||
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
|
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the appropriate base path for the visitor authentication cookie.
|
* Get the appropriate base path for the visitor authentication cookie.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user