mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 22:09:04 +00:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eac554014a | |||
| fef62b12b4 | |||
| 6a10b6185b | |||
| 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 | |||
| 88ffdcc303 | |||
| b6b09d42af | |||
| ebc39e9db9 | |||
| 0df3849ea2 | |||
| 8ed1bda2f2 | |||
| d67699a1f1 | |||
| 3363a18856 | |||
| ad1dc0b914 | |||
| e4c7307c99 | |||
| f9c942c8ff | |||
| eeb977fc03 | |||
| 4b8a62122a | |||
| e44e947fc1 | |||
| 7588cfe1c5 | |||
| a9a643f222 | |||
| 77d6cb75be | |||
| 1942d221df | |||
| 041601ed65 | |||
| 5567281a85 | |||
| 4c3753046b | |||
| 89f8e16184 | |||
| a3e1125ced | |||
| 4dae8acfed | |||
| bc081c4344 | |||
| e4275b235d | |||
| 77397cac82 |
@@ -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": 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,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support dark-mode specific page cover image
|
||||
@@ -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-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/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 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/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/react-openapi': patch
|
||||
---
|
||||
|
||||
Deduplicate path parameters from OpenAPI spec
|
||||
@@ -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/`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
deployment-url:
|
||||
description: "Deployment URL"
|
||||
value: ${{ steps.deploy.outputs.deployment-url }}
|
||||
value: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
@@ -55,6 +55,7 @@ runs:
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build worker
|
||||
@@ -62,19 +63,76 @@ runs:
|
||||
env:
|
||||
GITBOOK_RUNTIME: cloudflare
|
||||
shell: bash
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
|
||||
- name: Upload the DO worker
|
||||
uses: cloudflare/wrangler-action@v3.14.0
|
||||
with:
|
||||
apiToken: ${{ inputs.apiToken }}
|
||||
accountId: ${{ inputs.accountId }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '3.112.0'
|
||||
wranglerVersion: '4.10.0'
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
||||
DEPLOYMENT_URL: ${{ steps.upload_middleware.outputs.deployment-url }}
|
||||
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 }}"
|
||||
@@ -54,6 +54,7 @@ runs:
|
||||
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
|
||||
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
|
||||
GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE
|
||||
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
|
||||
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
|
||||
- name: Build Project Artifacts
|
||||
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
|
||||
| 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) |
|
||||
edit-mode: replace
|
||||
visual-testing-v1:
|
||||
@@ -200,7 +200,6 @@ jobs:
|
||||
name: Visual Testing v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
timeout-minutes: 10
|
||||
if: startsWith(github.ref_name, 'cloudflare/')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -263,7 +262,6 @@ jobs:
|
||||
name: Visual Testing Customers v2 (Cloudflare)
|
||||
needs: deploy-v2-cloudflare
|
||||
timeout-minutes: 8
|
||||
if: startsWith(github.ref_name, 'cloudflare/')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+1
@@ -9,6 +9,7 @@
|
||||
],
|
||||
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.biome": "explicit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center">GitBook</h1>
|
||||
|
||||
<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 align="center">
|
||||
@@ -59,15 +59,15 @@ bun install
|
||||
4. Start your local development server.
|
||||
|
||||
```
|
||||
bun dev
|
||||
bun dev:v2
|
||||
```
|
||||
|
||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||
|
||||
examples:
|
||||
|
||||
- http://localhost:3000/docs.gitbook.com
|
||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
||||
- http://localhost:3000/url/gitbook.com/docs
|
||||
- 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.
|
||||
|
||||
@@ -150,11 +150,11 @@ See `LICENSE` for more information.
|
||||
</p>
|
||||
|
||||
```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
|
||||
<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
|
||||
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||
/>
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"**/.wrangler/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json"
|
||||
"packages/icons/src/data/*.json",
|
||||
"packages/cache-do/worker-configuration.d.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
+4
-4
@@ -7,12 +7,12 @@
|
||||
"turbo": "^2.5.0",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"packageManager": "bun@1.2.8",
|
||||
"packageManager": "bun@1.2.11",
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"@gitbook/api": "0.109.0"
|
||||
"@gitbook/api": "^0.119.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -21,10 +21,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.112.0"
|
||||
"wrangler": "^4.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
"generate": "wrangler types",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
||||
"types": ["./worker-configuration.d.ts"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @gitbook/cache-tags
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 77397ca: Fix version of @gitbook/api referenced in package.json
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/api": "^0.119.0",
|
||||
"assert-never": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @gitbook/colors
|
||||
|
||||
## 0.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c3f6b8c: Update chroma ratio per step
|
||||
- 5e975ab: Fix code highlighting for HTTP
|
||||
- f7a3470: Change lightness check for color step 9 to allow input colors with a higher-than-needed contrast
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.3",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
|
||||
@@ -214,13 +214,31 @@ export function colorScale(
|
||||
const targetL =
|
||||
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.
|
||||
result.push(hex);
|
||||
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 = {
|
||||
L: targetL, // Blend lightness
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
# cloudflare
|
||||
.open-next
|
||||
.wrangler
|
||||
|
||||
# Symbolic links
|
||||
public
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# gitbook-v2
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3119066: Add support for reusable content across spaces.
|
||||
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1c8d9fe: keep data cache in OpenNext between deployment
|
||||
- 778624a: Only resize images with supported extensions.
|
||||
- e6ddc0f: Fix URL in sitemap
|
||||
- 5e975ab: Fix code highlighting for HTTP
|
||||
- e15757d: Fix crash on Cloudflare by using latest stable version of Next.js instead of canary
|
||||
- 634e0b4: Improve error messages around undefined site sections.
|
||||
- 97b7c79: Increase logging around caching behaviour causing page crashes.
|
||||
- 3f29206: Update the regex for validating site redirect
|
||||
- dd043df: Revert investigation work around URL caches.
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [77397ca]
|
||||
- @gitbook/cache-tags@0.3.1
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -7,10 +7,7 @@ const nextConfig = {
|
||||
experimental: {
|
||||
// This is needed to throw "forbidden" when the api token expired during revalidation
|
||||
authInterrupts: true,
|
||||
|
||||
// We don't use 'use cache' as it's not supported in OpenNext yet
|
||||
// and instead we scope `unstable_cache` to the parameters of the function
|
||||
useCache: false,
|
||||
useCache: true,
|
||||
|
||||
// Content is fully static, we can cache it in the session memory cache for a long time
|
||||
staleTimes: {
|
||||
@@ -34,6 +31,7 @@ const nextConfig = {
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
|
||||
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
|
||||
GITBOOK_IMAGE_RESIZE_MODE: process.env.GITBOOK_IMAGE_RESIZE_MODE,
|
||||
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
|
||||
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
|
||||
|
||||
@@ -55,6 +53,24 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/~gitbook/static/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: '*',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { defineCloudflareConfig } 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';
|
||||
import type { OpenNextConfig } from '@opennextjs/cloudflare';
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
|
||||
tagCache: doShardedTagCache({
|
||||
baseShardSize: 12,
|
||||
regionalCache: true,
|
||||
shardReplication: {
|
||||
numberOfSoftReplicas: 2,
|
||||
numberOfHardReplicas: 1,
|
||||
export default {
|
||||
default: {
|
||||
override: {
|
||||
wrapper: 'cloudflare-node',
|
||||
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),
|
||||
},
|
||||
}),
|
||||
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,127 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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,202 @@
|
||||
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');
|
||||
return localCacheEntry.json();
|
||||
}
|
||||
|
||||
const r2Object = await r2.get(cacheKey);
|
||||
if (!r2Object) return null;
|
||||
|
||||
span.setAttribute('cacheHit', 'r2');
|
||||
return {
|
||||
value: await r2Object.json(),
|
||||
lastModified: r2Object.uploaded.getTime(),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to get from cache', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async set<CacheType extends CacheEntryType = 'cache'>(
|
||||
key: string,
|
||||
value: CacheValue<CacheType>,
|
||||
cacheType?: CacheType
|
||||
): Promise<void> {
|
||||
const cacheKey = this.getR2Key(key, cacheType);
|
||||
return trace(
|
||||
{
|
||||
operation: 'openNextIncrementalCacheSet',
|
||||
name: cacheKey,
|
||||
},
|
||||
async (span) => {
|
||||
span.setAttribute('cacheType', cacheType ?? 'cache');
|
||||
const 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,22 +1,23 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.2.4",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"next": "canary",
|
||||
"@gitbook/api": "^0.119.0",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@opennextjs/cloudflare": "1.1.0",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^15.3.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"jwt-decode": "^4.0.0"
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gitbook": "*",
|
||||
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@7b3b305",
|
||||
"@types/rison": "^0.0.9",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8"
|
||||
@@ -28,7 +29,9 @@
|
||||
"build:v2": "next build",
|
||||
"start": "next start",
|
||||
"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",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getDataOrNull,
|
||||
throwIfDataError,
|
||||
} from '@v2/lib/data';
|
||||
import assertNever from 'assert-never';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { assert } from 'ts-essentials';
|
||||
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.
|
||||
linker.toLinkForContent = (rawURL: string) => {
|
||||
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)
|
||||
: null;
|
||||
|
||||
const siteSpace = (
|
||||
siteStructure.type === 'siteSpaces' && siteStructure.structure
|
||||
? siteStructure.structure
|
||||
: sections?.current.siteSpaces
|
||||
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||
if (!siteSpace) {
|
||||
throw new Error('Site space not found');
|
||||
}
|
||||
// Parse the current siteSpace and siteSpaces based on the site structure type.
|
||||
const { siteSpaces, siteSpace }: { siteSpaces: SiteSpace[]; siteSpace: SiteSpace } = (() => {
|
||||
if (siteStructure.type === 'siteSpaces') {
|
||||
const siteSpaces = siteStructure.structure;
|
||||
const siteSpace = siteSpaces.find((siteSpace) => siteSpace.id === ids.siteSpace);
|
||||
|
||||
const siteSpaces =
|
||||
siteStructure.type === 'siteSpaces'
|
||||
? siteStructure.structure
|
||||
: (sections?.current.siteSpaces ?? []);
|
||||
if (!siteSpace) {
|
||||
throw new Error(
|
||||
`Site space "${ids.siteSpace}" not found in structure type="siteSpaces"`
|
||||
);
|
||||
}
|
||||
|
||||
return { siteSpaces, siteSpace };
|
||||
}
|
||||
|
||||
if (siteStructure.type === 'sections') {
|
||||
assert(
|
||||
sections,
|
||||
`cannot find site space "${ids.siteSpace}" because parsed sections are missing siteStructure.type="sections" siteSection="${ids.siteSection}"`
|
||||
);
|
||||
|
||||
const currentSection = sections.current;
|
||||
const siteSpaces = currentSection.siteSpaces;
|
||||
const siteSpace = currentSection.siteSpaces.find(
|
||||
(siteSpace) => siteSpace.id === ids.siteSpace
|
||||
);
|
||||
|
||||
if (!siteSpace) {
|
||||
throw new Error(
|
||||
`Site space "${ids.siteSpace}" not found in structure type="sections" currentSection="${currentSection.id}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { siteSpaces, siteSpace };
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
assertNever(siteStructure, `cannot handle site structure of type ${siteStructure.type}`);
|
||||
})();
|
||||
|
||||
const customization = (() => {
|
||||
if (ids.siteSpace) {
|
||||
@@ -380,7 +407,7 @@ export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
|
||||
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
|
||||
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,3 @@ export function getCloudflareContext() {
|
||||
|
||||
return getCloudflareContextOpenNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an object representing the current request.
|
||||
*/
|
||||
export function getCloudflareRequestGlobal() {
|
||||
const context = getCloudflareContext();
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return context.cf;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ export async function wrapDataFetcherError<T>(
|
||||
*/
|
||||
export function getExposableError(error: Error): DataFetcherErrorData {
|
||||
if (error instanceof GitBookAPIError) {
|
||||
if (error.code >= 500) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.errorMessage,
|
||||
@@ -105,6 +109,10 @@ export function getExposableError(error: Error): DataFetcherErrorData {
|
||||
}
|
||||
|
||||
if (error instanceof DataFetcherError) {
|
||||
if (error.code >= 500) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
|
||||
@@ -4,5 +4,4 @@ export * from './pages';
|
||||
export * from './urls';
|
||||
export * from './errors';
|
||||
export * from './lookup';
|
||||
export * from './proxy';
|
||||
export * from './visitor';
|
||||
|
||||
@@ -1,55 +1,102 @@
|
||||
import { race, tryCatch } from '@/lib/async';
|
||||
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
|
||||
import { trace } from '@/lib/tracing';
|
||||
import type { PublishedSiteContentLookup } from '@gitbook/api';
|
||||
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
|
||||
import { apiClient } from './api';
|
||||
import { getExposableError } from './errors';
|
||||
import type { DataFetcherResponse } from './types';
|
||||
import { getURLLookupAlternatives, stripURLSearch } from './urls';
|
||||
|
||||
/**
|
||||
* Lookup a content by its URL using the GitBook API.
|
||||
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||
*/
|
||||
export async function getPublishedContentByURL(input: {
|
||||
interface LookupPublishedContentByUrlInput {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
apiToken: string | null;
|
||||
visitorPayload: SiteVisitorPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
|
||||
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||
*/
|
||||
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
|
||||
return lookupPublishedContentByUrl({
|
||||
url: input.url,
|
||||
fetchLookupAPIResult: ({ url, signal }) => {
|
||||
const api = apiClient({ apiToken: input.apiToken });
|
||||
return trace(
|
||||
{
|
||||
operation: 'resolvePublishedContentByUrl',
|
||||
name: url,
|
||||
},
|
||||
() =>
|
||||
tryCatch(
|
||||
api.urls.resolvePublishedContentByUrl(
|
||||
{
|
||||
url,
|
||||
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
|
||||
redirectOnError: input.redirectOnError,
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
|
||||
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
|
||||
*
|
||||
* @deprecated use resolvePublishedContentByUrl.
|
||||
*
|
||||
*/
|
||||
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
|
||||
return lookupPublishedContentByUrl({
|
||||
url: input.url,
|
||||
fetchLookupAPIResult: ({ url, signal }) => {
|
||||
const api = apiClient({ apiToken: input.apiToken });
|
||||
return trace(
|
||||
{
|
||||
operation: 'getPublishedContentByURL',
|
||||
name: url,
|
||||
},
|
||||
() =>
|
||||
tryCatch(
|
||||
api.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url,
|
||||
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
|
||||
redirectOnError: input.redirectOnError,
|
||||
// @ts-expect-error - cacheVersion is not a real query param
|
||||
cacheVersion: 'v2',
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
|
||||
|
||||
async function lookupPublishedContentByUrl(input: {
|
||||
url: string;
|
||||
fetchLookupAPIResult: (args: {
|
||||
url: string;
|
||||
signal: AbortSignal;
|
||||
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
|
||||
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
|
||||
const lookupURL = new URL(input.url);
|
||||
const url = stripURLSearch(lookupURL);
|
||||
const lookup = getURLLookupAlternatives(url);
|
||||
|
||||
const result = await race(lookup.urls, async (alternative, { signal }) => {
|
||||
const api = await apiClient({ apiToken: input.apiToken });
|
||||
|
||||
const callResult = await trace(
|
||||
{
|
||||
operation: 'getPublishedContentByURL',
|
||||
name: alternative.url,
|
||||
},
|
||||
() =>
|
||||
tryCatch(
|
||||
api.urls.getPublishedContentByUrl(
|
||||
{
|
||||
url: alternative.url,
|
||||
visitorAuthToken: input.visitorAuthToken ?? undefined,
|
||||
redirectOnError: input.redirectOnError,
|
||||
|
||||
// As this endpoint is cached by our API, we version the request
|
||||
// to void getting stale data with missing properties.
|
||||
// this could be improved by ensuring our API cache layer is versioned
|
||||
// or invalidated when needed
|
||||
// @ts-expect-error - cacheVersion is not a real query param
|
||||
cacheVersion: 'v2',
|
||||
},
|
||||
{
|
||||
signal,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const callResult = await input.fetchLookupAPIResult({
|
||||
url: alternative.url,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (callResult.error) {
|
||||
if (alternative.primary) {
|
||||
|
||||
@@ -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 (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: (...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(...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;
|
||||
}
|
||||
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
|
||||
input: api.AIMessageInput[];
|
||||
output: api.AIOutputFormat;
|
||||
model: api.AIModel;
|
||||
instructions?: string;
|
||||
previousResponseId?: string;
|
||||
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
|
||||
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.
|
||||
|
||||
+18
@@ -85,6 +85,15 @@ export const GITBOOK_IMAGE_RESIZE_URL = process.env.GITBOOK_IMAGE_RESIZE_URL ??
|
||||
export const GITBOOK_IMAGE_RESIZE_SIGNING_KEY =
|
||||
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY ?? null;
|
||||
|
||||
/**
|
||||
* Mode used for resizing images.
|
||||
*/
|
||||
export const GITBOOK_IMAGE_RESIZE_MODE = enforceEnum(
|
||||
'GITBOOK_IMAGE_RESIZE_MODE',
|
||||
process.env.GITBOOK_IMAGE_RESIZE_MODE || 'cdn-cgi',
|
||||
['cdn-cgi', 'cf-fetch']
|
||||
);
|
||||
|
||||
/**
|
||||
* Endpoint where icons are served.
|
||||
*/
|
||||
@@ -100,3 +109,12 @@ export const GITBOOK_ICONS_TOKEN = process.env.GITBOOK_ICONS_TOKEN;
|
||||
* Secret used to validate requests from the GitBook app.
|
||||
*/
|
||||
export const GITBOOK_SECRET = process.env.GITBOOK_SECRET ?? null;
|
||||
|
||||
function enforceEnum<T extends string>(key: string, value: string, enumValues: T[]): T {
|
||||
if (!enumValues.includes(value as T)) {
|
||||
throw new Error(
|
||||
`Invalid value for ${key}: "${value}", expected one of: ${enumValues.join(', ')}`
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||
|
||||
describe('checkIsSizableImageURL', () => {
|
||||
it('should return Skip for non-parsable URLs', () => {
|
||||
expect(checkIsSizableImageURL('not a url')).toBe(SizableImageAction.Skip);
|
||||
});
|
||||
|
||||
it('should return Skip for non-http(s) URLs', () => {
|
||||
expect(checkIsSizableImageURL('data:image/png;base64,abc')).toBe(SizableImageAction.Skip);
|
||||
expect(checkIsSizableImageURL('file:///path/to/image.jpg')).toBe(SizableImageAction.Skip);
|
||||
});
|
||||
|
||||
it('should return Skip for localhost URLs', () => {
|
||||
expect(checkIsSizableImageURL('http://localhost:3000/image.jpg')).toBe(
|
||||
SizableImageAction.Skip
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://localhost/image.png')).toBe(SizableImageAction.Skip);
|
||||
});
|
||||
|
||||
it('should return Skip for GitBook image URLs', () => {
|
||||
expect(checkIsSizableImageURL('https://example.com/~gitbook/image/test.jpg')).toBe(
|
||||
SizableImageAction.Skip
|
||||
);
|
||||
});
|
||||
|
||||
it('should return Resize for supported image extensions', () => {
|
||||
expect(checkIsSizableImageURL('https://example.com/image.jpg')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.jpeg')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.png')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.gif')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.webp')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
});
|
||||
|
||||
it('should return Resize for URLs without extensions', () => {
|
||||
expect(checkIsSizableImageURL('https://example.com/image')).toBe(SizableImageAction.Resize);
|
||||
});
|
||||
|
||||
it('should return Passthrough for unsupported image extensions', () => {
|
||||
expect(checkIsSizableImageURL('https://example.com/image.svg')).toBe(
|
||||
SizableImageAction.Passthrough
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.bmp')).toBe(
|
||||
SizableImageAction.Passthrough
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.tiff')).toBe(
|
||||
SizableImageAction.Passthrough
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.ico')).toBe(
|
||||
SizableImageAction.Passthrough
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters correctly', () => {
|
||||
expect(checkIsSizableImageURL('https://example.com/image.jpg?width=100')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.svg?height=200')).toBe(
|
||||
SizableImageAction.Passthrough
|
||||
);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for extensions', () => {
|
||||
expect(checkIsSizableImageURL('https://example.com/image.JPG')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
expect(checkIsSizableImageURL('https://example.com/image.PNG')).toBe(
|
||||
SizableImageAction.Resize
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getExtension } from '@/lib/paths';
|
||||
|
||||
export enum SizableImageAction {
|
||||
Resize = 'resize',
|
||||
Skip = 'skip',
|
||||
Passthrough = 'passthrough',
|
||||
}
|
||||
|
||||
/**
|
||||
* https://developers.cloudflare.com/images/transform-images/#supported-input-formats
|
||||
*/
|
||||
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
/**
|
||||
* Check if an image URL is resizable.
|
||||
* Skip it for non-http(s) URLs (data, etc).
|
||||
* Skip it for SVGs.
|
||||
* Skip it for GitBook images (to avoid recursion).
|
||||
*/
|
||||
export function checkIsSizableImageURL(input: string): SizableImageAction {
|
||||
if (!URL.canParse(input)) {
|
||||
return SizableImageAction.Skip;
|
||||
}
|
||||
|
||||
const parsed = new URL(input);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return SizableImageAction.Skip;
|
||||
}
|
||||
if (parsed.hostname === 'localhost') {
|
||||
return SizableImageAction.Skip;
|
||||
}
|
||||
if (parsed.pathname.includes('/~gitbook/image')) {
|
||||
return SizableImageAction.Skip;
|
||||
}
|
||||
|
||||
const extension = getExtension(parsed.pathname).toLowerCase();
|
||||
if (!extension || SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
|
||||
// If no extension, we consider it resizable.
|
||||
return SizableImageAction.Resize;
|
||||
}
|
||||
|
||||
return SizableImageAction.Passthrough;
|
||||
}
|
||||
@@ -1,34 +1,11 @@
|
||||
import 'server-only';
|
||||
|
||||
import { GITBOOK_IMAGE_RESIZE_SIGNING_KEY, GITBOOK_IMAGE_RESIZE_URL } from '../env';
|
||||
import type { GitBookLinker } from '../links';
|
||||
import { SizableImageAction, checkIsSizableImageURL } from './checkIsSizableImageURL';
|
||||
import { getImageSize } from './resizer';
|
||||
import { type SignatureVersion, generateImageSignature } from './signatures';
|
||||
import type { ImageResizer } from './types';
|
||||
|
||||
interface CloudflareImageJsonFormat {
|
||||
width: number;
|
||||
height: number;
|
||||
original: {
|
||||
file_size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
|
||||
*/
|
||||
export interface CloudflareImageOptions {
|
||||
format?: 'webp' | 'avif' | 'json' | 'jpeg';
|
||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpr?: number;
|
||||
anim?: boolean;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image resizer for a rendering context.
|
||||
*/
|
||||
@@ -47,7 +24,7 @@ export function createImageResizer({
|
||||
|
||||
return {
|
||||
getResizedImageURL: (urlInput) => {
|
||||
if (!checkIsSizableImageURL(urlInput)) {
|
||||
if (checkIsSizableImageURL(urlInput) === SizableImageAction.Skip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -87,7 +64,7 @@ export function createImageResizer({
|
||||
},
|
||||
|
||||
getImageSize: async (input, options) => {
|
||||
if (!checkIsSizableImageURL(input)) {
|
||||
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,124 +83,6 @@ export function createNoopImageResizer(): ImageResizer {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an HTTP URL.
|
||||
*/
|
||||
export function checkIsHttpURL(input: string | URL): boolean {
|
||||
if (!URL.canParse(input)) {
|
||||
return false;
|
||||
}
|
||||
const parsed = new URL(input);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an image URL is resizable.
|
||||
* Skip it for non-http(s) URLs (data, etc).
|
||||
* Skip it for SVGs.
|
||||
* Skip it for GitBook images (to avoid recursion).
|
||||
*/
|
||||
export function checkIsSizableImageURL(input: string): boolean {
|
||||
if (!URL.canParse(input)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.includes('/~gitbook/image')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = new URL(input);
|
||||
if (parsed.pathname.endsWith('.svg') || parsed.pathname.endsWith('.avif')) {
|
||||
return false;
|
||||
}
|
||||
if (!checkIsHttpURL(parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of an image.
|
||||
*/
|
||||
export async function getImageSize(
|
||||
input: string,
|
||||
defaultSize: Partial<CloudflareImageOptions> = {}
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
if (!checkIsSizableImageURL(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await resizeImage(input, {
|
||||
// Abort the request after 2 seconds to avoid blocking rendering for too long
|
||||
signal: AbortSignal.timeout(2000),
|
||||
// Measure size and resize it to the most common size
|
||||
// to optimize caching
|
||||
...defaultSize,
|
||||
format: 'json',
|
||||
anim: false,
|
||||
});
|
||||
|
||||
const json = (await response.json()) as CloudflareImageJsonFormat;
|
||||
return {
|
||||
width: json.original.width,
|
||||
height: json.original.height,
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Cloudflare Image Resize operation on an image.
|
||||
*/
|
||||
export async function resizeImage(
|
||||
input: string,
|
||||
options: CloudflareImageOptions & {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { signal, ...resizeOptions } = options;
|
||||
|
||||
const parsed = new URL(input);
|
||||
if (parsed.protocol === 'data:') {
|
||||
throw new Error('Cannot resize data: URLs');
|
||||
}
|
||||
|
||||
if (parsed.hostname === 'localhost') {
|
||||
throw new Error('Cannot resize localhost URLs');
|
||||
}
|
||||
|
||||
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
|
||||
// we need to use the Cloudflare Image Resize API directly.
|
||||
if (!GITBOOK_IMAGE_RESIZE_URL) {
|
||||
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set');
|
||||
}
|
||||
|
||||
return await fetch(
|
||||
`${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
|
||||
resizeOptions
|
||||
)}/${encodeURIComponent(input)}`,
|
||||
{
|
||||
headers: {
|
||||
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||
Accept:
|
||||
resizeOptions.format === 'json'
|
||||
? 'application/json'
|
||||
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||
},
|
||||
signal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyOptions(options: CloudflareImageOptions): string {
|
||||
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
|
||||
return `${rest}${rest ? ',' : ''}${key}=${value}`;
|
||||
}, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Because of a bug in Cloudflare, 127.0.0.1 is replaced by localhost.
|
||||
* We protect against it by converting to a special token, and then parsing
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getImageResizingContextId } from './getImageResizingContextId';
|
||||
|
||||
describe('getImageResizingContextId', () => {
|
||||
it('should return proxy identifier for proxy requests', () => {
|
||||
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
|
||||
expect(getImageResizingContextId(proxyRequestURL)).toBe('sites/site_foo');
|
||||
});
|
||||
|
||||
it('should return preview identifier for preview requests', () => {
|
||||
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
|
||||
expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo');
|
||||
});
|
||||
|
||||
it('should return host for regular requests', () => {
|
||||
const regularRequestURL = new URL('https://example.com/docs/foo/hello/world');
|
||||
expect(getImageResizingContextId(regularRequestURL)).toBe('example.com');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getProxyRequestIdentifier, isProxyRequest } from '../data';
|
||||
import { getPreviewRequestIdentifier, isPreviewRequest } from '@v2/lib/preview';
|
||||
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
|
||||
|
||||
/**
|
||||
* Get the site identifier to use for image resizing for an incoming request.
|
||||
@@ -8,6 +9,9 @@ export function getImageResizingContextId(url: URL): string {
|
||||
if (isProxyRequest(url)) {
|
||||
return getProxyRequestIdentifier(url);
|
||||
}
|
||||
if (isPreviewRequest(url)) {
|
||||
return getPreviewRequestIdentifier(url);
|
||||
}
|
||||
|
||||
return url.host;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,5 @@ export * from './createImageResizer';
|
||||
export * from './signatures';
|
||||
export * from './utils';
|
||||
export * from './getImageResizingContextId';
|
||||
export * from './resizer';
|
||||
export * from './checkIsSizableImageURL';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { GITBOOK_IMAGE_RESIZE_URL } from '@v2/lib/env';
|
||||
import type { CloudflareImageOptions } from './types';
|
||||
import { copyImageResponse } from './utils';
|
||||
|
||||
/**
|
||||
* Resize an image by doing a request to a /cdn/cgi/ endpoint.
|
||||
* https://developers.cloudflare.com/images/transform-images/transform-via-url/
|
||||
*/
|
||||
export async function resizeImageWithCDNCgi(
|
||||
input: string,
|
||||
options: CloudflareImageOptions & {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { signal, ...resizeOptions } = options;
|
||||
|
||||
// Since Cloudflare Images options on fetch are not supported on Cloudflare Pages,
|
||||
// we need to use the Cloudflare Image Resize API directly.
|
||||
if (!GITBOOK_IMAGE_RESIZE_URL) {
|
||||
throw new Error('GITBOOK_IMAGE_RESIZE_URL is not set for cdn-cgi image resize mode');
|
||||
}
|
||||
|
||||
const resizeURL = `${GITBOOK_IMAGE_RESIZE_URL}${stringifyOptions(
|
||||
resizeOptions
|
||||
)}/${encodeURIComponent(input)}`;
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
|
||||
console.log(`resize image using cdn-cgi: ${resizeURL}`);
|
||||
|
||||
return copyImageResponse(
|
||||
await fetch(resizeURL, {
|
||||
headers: {
|
||||
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||
Accept:
|
||||
resizeOptions.format === 'json'
|
||||
? 'application/json'
|
||||
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function stringifyOptions(options: CloudflareImageOptions): string {
|
||||
return Object.entries({ ...options }).reduce((rest, [key, value]) => {
|
||||
return `${rest}${rest ? ',' : ''}${key}=${value}`;
|
||||
}, '');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CloudflareImageOptions } from './types';
|
||||
import { copyImageResponse } from './utils';
|
||||
|
||||
/**
|
||||
* Resize an image by doing a request to the image itself using the Cloudflare fetch.
|
||||
* https://developers.cloudflare.com/images/transform-images/transform-via-workers/
|
||||
*
|
||||
* This method doesn't work in Cloudflare Pages and is only supported in workers.
|
||||
*/
|
||||
export async function resizeImageWithCFFetch(
|
||||
input: string,
|
||||
options: CloudflareImageOptions & {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { signal, ...resizeOptions } = options;
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole: this log is useful for debugging
|
||||
console.log(`resize image using cf-fetch: ${input}`);
|
||||
|
||||
return copyImageResponse(
|
||||
await fetch(input, {
|
||||
headers: {
|
||||
// Pass the `Accept` header, as Cloudflare uses this to validate the format.
|
||||
Accept:
|
||||
resizeOptions.format === 'json'
|
||||
? 'application/json'
|
||||
: `image/${resizeOptions.format || 'jpeg'}`,
|
||||
},
|
||||
signal,
|
||||
cf: { image: resizeOptions },
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './resizeImage';
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'server-only';
|
||||
import assertNever from 'assert-never';
|
||||
import { GITBOOK_IMAGE_RESIZE_MODE } from '../../env';
|
||||
import { SizableImageAction, checkIsSizableImageURL } from '../checkIsSizableImageURL';
|
||||
import { resizeImageWithCDNCgi } from './cdn-cgi';
|
||||
import { resizeImageWithCFFetch } from './cf-fetch';
|
||||
import type { CloudflareImageJsonFormat, CloudflareImageOptions } from './types';
|
||||
|
||||
/**
|
||||
* Get the size of an image.
|
||||
*/
|
||||
export async function getImageSize(
|
||||
input: string,
|
||||
defaultSize: Partial<CloudflareImageOptions> = {}
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
if (checkIsSizableImageURL(input) !== SizableImageAction.Resize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await resizeImage(input, {
|
||||
// Abort the request after 2 seconds to avoid blocking rendering for too long
|
||||
signal: AbortSignal.timeout(2000),
|
||||
// Measure size and resize it to the most common size
|
||||
// to optimize caching
|
||||
...defaultSize,
|
||||
format: 'json',
|
||||
anim: false,
|
||||
});
|
||||
|
||||
const json = (await response.json()) as CloudflareImageJsonFormat;
|
||||
return {
|
||||
width: json.original.width,
|
||||
height: json.original.height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Error getting image size for ${input}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Cloudflare Image Resize operation on an image.
|
||||
*/
|
||||
export async function resizeImage(
|
||||
input: string,
|
||||
options: CloudflareImageOptions & {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const action = checkIsSizableImageURL(input);
|
||||
if (action === SizableImageAction.Skip) {
|
||||
throw new Error(
|
||||
'Cannot resize this image, this function should have never been called on this url'
|
||||
);
|
||||
}
|
||||
|
||||
if (action === SizableImageAction.Passthrough) {
|
||||
return fetch(input, {
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
switch (GITBOOK_IMAGE_RESIZE_MODE) {
|
||||
case 'cdn-cgi':
|
||||
return resizeImageWithCDNCgi(input, options);
|
||||
case 'cf-fetch':
|
||||
return resizeImageWithCFFetch(input, options);
|
||||
default:
|
||||
assertNever(GITBOOK_IMAGE_RESIZE_MODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface CloudflareImageJsonFormat {
|
||||
width: number;
|
||||
height: number;
|
||||
original: {
|
||||
file_size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* https://developers.cloudflare.com/images/image-resizing/resize-with-workers/
|
||||
*/
|
||||
export interface CloudflareImageOptions {
|
||||
format?: 'webp' | 'avif' | 'json' | 'jpeg';
|
||||
fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpr?: number;
|
||||
anim?: boolean;
|
||||
quality?: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copy a response to make sure it can be mutated by the rest of the middleware.
|
||||
* To avoid errors "Can't modify immutable headers".
|
||||
*/
|
||||
export function copyImageResponse(response: Response) {
|
||||
return new Response(response.body, response);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const siteGitBookIO = createLinker({
|
||||
siteBasePath: '/sitename/',
|
||||
});
|
||||
|
||||
describe('toPathInContent', () => {
|
||||
describe('toPathInSpace', () => {
|
||||
it('should return the correct path', () => {
|
||||
expect(root.toPathInSpace('some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('some/path')).toBe('/section/variant/some/path');
|
||||
@@ -29,6 +29,16 @@ describe('toPathInContent', () => {
|
||||
expect(root.toPathInSpace('/some/path')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('/some/path')).toBe('/section/variant/some/path');
|
||||
});
|
||||
|
||||
it('should remove the trailing slash', () => {
|
||||
expect(root.toPathInSpace('some/path/')).toBe('/some/path');
|
||||
expect(variantInSection.toPathInSpace('some/path/')).toBe('/section/variant/some/path');
|
||||
});
|
||||
|
||||
it('should not add a trailing slash', () => {
|
||||
expect(root.toPathInSpace('')).toBe('');
|
||||
expect(variantInSection.toPathInSpace('')).toBe('/section/variant');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPathInSite', () => {
|
||||
@@ -36,6 +46,16 @@ describe('toPathInSite', () => {
|
||||
expect(root.toPathInSite('some/path')).toBe('/some/path');
|
||||
expect(siteGitBookIO.toPathInSite('some/path')).toBe('/sitename/some/path');
|
||||
});
|
||||
|
||||
it('should remove the trailing slash', () => {
|
||||
expect(root.toPathInSite('some/path/')).toBe('/some/path');
|
||||
expect(siteGitBookIO.toPathInSite('some/path/')).toBe('/sitename/some/path');
|
||||
});
|
||||
|
||||
it('should not add a trailing slash', () => {
|
||||
expect(root.toPathInSite('')).toBe('');
|
||||
expect(siteGitBookIO.toPathInSite('')).toBe('/sitename');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRelativePathInSite', () => {
|
||||
|
||||
@@ -93,6 +93,11 @@ export function createLinker(
|
||||
},
|
||||
|
||||
toAbsoluteURL(absolutePath: string): string {
|
||||
// If the path is already a full URL, we return it as is.
|
||||
if (URL.canParse(absolutePath)) {
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
if (!servedOn.host) {
|
||||
return absolutePath;
|
||||
}
|
||||
@@ -123,5 +128,9 @@ export function createLinker(
|
||||
function joinPaths(prefix: string, path: string): string {
|
||||
const prefixPath = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||||
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
return prefixPath + suffixPath;
|
||||
return removeTrailingSlash(prefixPath + suffixPath);
|
||||
}
|
||||
|
||||
function removeTrailingSlash(path: string): string {
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
|
||||
|
||||
describe('isPreviewRequest', () => {
|
||||
it('should return true for preview requests', () => {
|
||||
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
|
||||
expect(isPreviewRequest(previewRequestURL)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-preview requests', () => {
|
||||
const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world');
|
||||
expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviewRequestIdentifier', () => {
|
||||
it('should return the correct identifier for preview requests', () => {
|
||||
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
|
||||
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Check if the request to the site is a preview request.
|
||||
*/
|
||||
export function isPreviewRequest(requestURL: URL): boolean {
|
||||
return requestURL.host === 'preview';
|
||||
}
|
||||
|
||||
export function getPreviewRequestIdentifier(requestURL: URL): string {
|
||||
// For preview requests, we extract the site ID from the pathname
|
||||
// e.g. https://preview/site_id/...
|
||||
const pathname = requestURL.pathname.slice(1).split('/');
|
||||
return pathname[0];
|
||||
}
|
||||
@@ -10,15 +10,16 @@ import {
|
||||
type ResponseCookies,
|
||||
getPathScopedCookieName,
|
||||
getResponseCookiesForVisitorAuth,
|
||||
getVisitorToken,
|
||||
getVisitorData,
|
||||
normalizeVisitorAuthURL,
|
||||
} from '@/lib/visitor-token';
|
||||
} from '@/lib/visitors';
|
||||
import { serveResizedImage } from '@/routes/image';
|
||||
import {
|
||||
DataFetcherError,
|
||||
getPublishedContentByURL,
|
||||
getVisitorAuthBasePath,
|
||||
normalizeURL,
|
||||
resolvePublishedContentByUrl,
|
||||
throwIfDataError,
|
||||
} from '@v2/lib/data';
|
||||
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
|
||||
@@ -33,6 +34,15 @@ export const config = {
|
||||
|
||||
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
|
||||
|
||||
/**
|
||||
* Temporary list of hosts to test adaptive content using the new resolution API.
|
||||
*/
|
||||
const ADAPTIVE_CONTENT_HOSTS = [
|
||||
'docs.gitbook.com',
|
||||
'adaptive-docs.gitbook-staging.com',
|
||||
'enriched-content-playground.gitbook-staging.io',
|
||||
];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
try {
|
||||
const requestURL = new URL(request.url);
|
||||
@@ -85,17 +95,22 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
//
|
||||
// Detect and extract the visitor authentication token from the request
|
||||
//
|
||||
// @ts-ignore - request typing
|
||||
const visitorToken = getVisitorToken({
|
||||
const { visitorToken, unsignedClaims, visitorParamsCookie } = getVisitorData({
|
||||
cookies: request.cookies.getAll(),
|
||||
url: siteRequestURL,
|
||||
});
|
||||
|
||||
const withAPIToken = async (apiToken: string | null) => {
|
||||
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
|
||||
? resolvePublishedContentByUrl
|
||||
: getPublishedContentByURL;
|
||||
const siteURLData = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
resolve({
|
||||
url: siteRequestURL.toString(),
|
||||
visitorAuthToken: visitorToken?.token ?? null,
|
||||
visitorPayload: {
|
||||
jwtToken: visitorToken?.token ?? undefined,
|
||||
unsignedClaims,
|
||||
},
|
||||
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
|
||||
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
|
||||
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
|
||||
@@ -106,7 +121,13 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
apiToken,
|
||||
})
|
||||
);
|
||||
const cookies: ResponseCookies = [];
|
||||
|
||||
const cookies: ResponseCookies = visitorParamsCookie
|
||||
? [
|
||||
// If visitor.* params were passed to the site URL, include a session cookie to persist these params across navigation.
|
||||
visitorParamsCookie,
|
||||
]
|
||||
: [];
|
||||
|
||||
//
|
||||
// Handle redirects
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
interface JsonBody {
|
||||
// The paths need to be the rewritten one, `res.revalidate` call don't go through the middleware
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
const signatureHeader = req.headers['x-gitbook-signature'] as string | undefined;
|
||||
if (!signatureHeader) {
|
||||
return res.status(400).json({ error: 'Missing signature header' });
|
||||
}
|
||||
// We cannot use env from `@/v2/lib/env` here as it make it crash because of the import "server-only" in the file.
|
||||
if (process.env.GITBOOK_SECRET) {
|
||||
try {
|
||||
const computedSignature = crypto
|
||||
.createHmac('sha256', process.env.GITBOOK_SECRET)
|
||||
.update(JSON.stringify(req.body))
|
||||
.digest('hex');
|
||||
|
||||
if (computedSignature === signatureHeader) {
|
||||
const results = await Promise.allSettled(
|
||||
(req.body as JsonBody).paths.map((path) => {
|
||||
// biome-ignore lint/suspicious/noConsole: we want to log here
|
||||
console.log(`Revalidating path: ${path}`);
|
||||
return res.revalidate(path);
|
||||
})
|
||||
);
|
||||
return res.status(200).json({
|
||||
success: results.every((result) => result.status === 'fulfilled'),
|
||||
errors: results
|
||||
.filter((result) => result.status === 'rejected')
|
||||
.map((result) => (result as PromiseRejectedResult).reason),
|
||||
});
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
} catch (error) {
|
||||
console.error('Error during revalidation:', error);
|
||||
return res.status(400).json({ error: 'Invalid request or unable to parse JSON' });
|
||||
}
|
||||
}
|
||||
// If no secret is set, we do not allow revalidation
|
||||
return res.status(403).json({ error: 'Revalidation is disabled' });
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"main": ".open-next/worker.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-03-11",
|
||||
"compatibility_flags": ["nodejs_compat", "allow_importable_env"],
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"assets": {
|
||||
"directory": ".open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
@@ -10,15 +14,15 @@
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true",
|
||||
"IS_PREVIEW": "false"
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "NEXT_CACHE_D1",
|
||||
"database_id": "f59ddb40-ad72-4312-9395-0ac6a129af8e",
|
||||
"database_name": "gitbook-open-v2-tags-preview"
|
||||
}
|
||||
],
|
||||
"vars": {
|
||||
"IS_PREVIEW": "true"
|
||||
},
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "NEXT_INC_CACHE_R2_BUCKET",
|
||||
@@ -55,13 +59,6 @@
|
||||
"bucket_name": "gitbook-open-v2-cache-staging"
|
||||
}
|
||||
],
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "NEXT_CACHE_D1",
|
||||
"database_id": "9df62e39-1f35-4066-83aa-e9b8ed3ac8d5",
|
||||
"database_name": "gitbook-open-v2-tags-staging"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
@@ -97,6 +94,14 @@
|
||||
]
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "open-2c.gitbook.com/*",
|
||||
@@ -113,13 +118,6 @@
|
||||
"bucket_name": "gitbook-open-v2-cache-production"
|
||||
}
|
||||
],
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "NEXT_CACHE_D1",
|
||||
"database_id": "a6f16fce-5f45-43a9-89a4-7b83ddf25b77",
|
||||
"database_name": "gitbook-open-v2-tags-production"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "WORKER_SELF_REFERENCE",
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
# gitbook
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8339e91: Fix images in reusable content across spaces.
|
||||
- 326e28e: Design tweaks to code blocks and OpenAPI pages
|
||||
- 3119066: Add support for reusable content across spaces.
|
||||
- 7d7806d: Pass SVG images through image resizing without resizing them to serve them from optimal host.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c4ebb3f: Fix openapi-select hover in responses
|
||||
- aed79fd: Decrease rounding of header logo
|
||||
- 42ca7e1: Fix openapi CR preview
|
||||
- e6ddc0f: Fix URL in sitemap
|
||||
- 5e975ab: Fix code highlighting for HTTP
|
||||
- 5d504ff: Fix resolution of links in reusable contents
|
||||
- 95a1f65: Better print layouts: wrap code blocks & force table column auto-sizing
|
||||
- 0499966: Fix invalid sitemap.xml generated with relative URLs instead of absolute ones
|
||||
- 2a805cc: Change OpenAPI schema-optional from `info` to `tint` color
|
||||
- 580101d: Fix schemas disclosure label causing client error
|
||||
- 12a455d: Fix OpenAPI layout issues
|
||||
- 97b7c79: Increase logging around caching behaviour causing page crashes.
|
||||
- 373f18f: Prevent section group popovers from opening on click
|
||||
- 3f29206: Update the regex for validating site redirect
|
||||
- 0c973a3: Always link main logo to the root of the site
|
||||
- ae5f1ab: Change `Dropdown`s to use Radix's `DropdownMenu`
|
||||
- 0e201d5: Add border to filled sidebar on gradient theme
|
||||
- dd043df: Revert investigation work around URL caches.
|
||||
- 89a5816: Fix OpenAPI disclosure label ("Show properties") misalignment on mobile
|
||||
- Updated dependencies [c3f6b8c]
|
||||
- Updated dependencies [d00dc8c]
|
||||
- Updated dependencies [42ca7e1]
|
||||
- Updated dependencies [326e28e]
|
||||
- Updated dependencies [5e975ab]
|
||||
- Updated dependencies [f7a3470]
|
||||
- Updated dependencies [580101d]
|
||||
- Updated dependencies [20ebecb]
|
||||
- Updated dependencies [80cb52a]
|
||||
- Updated dependencies [cb5598d]
|
||||
- Updated dependencies [c6637b0]
|
||||
- Updated dependencies [a3ec264]
|
||||
- @gitbook/colors@0.3.3
|
||||
- @gitbook/openapi-parser@2.1.4
|
||||
- @gitbook/react-openapi@1.3.0
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ebc39e9]
|
||||
- Updated dependencies [b6b09d4]
|
||||
- @gitbook/react-openapi@1.2.1
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d67699a: Add OpenAPI Webhook block
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4b8a621: Show sections tabs only if there is at least two sections
|
||||
- 8ed1bda: Translate OpenAPI blocks
|
||||
- 7588cfe: Improve OpenAPIResponses examples and schemas
|
||||
- Updated dependencies [eeb977f]
|
||||
- Updated dependencies [3363a18]
|
||||
- Updated dependencies [d67699a]
|
||||
- Updated dependencies [8ed1bda]
|
||||
- Updated dependencies [7588cfe]
|
||||
- Updated dependencies [ad1dc0b]
|
||||
- @gitbook/react-openapi@1.2.0
|
||||
|
||||
## 0.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [77397ca]
|
||||
- @gitbook/cache-tags@0.3.1
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
@@ -530,7 +610,7 @@
|
||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||
- 3996110: Optimize images rendered in community ads
|
||||
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||
- 5096f7f: Disable KV cache for gitbook.com/docs as a test, also disable it for change-request to improve consistency
|
||||
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||
|
||||
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
|
||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nexthink',
|
||||
contentBaseURL: 'https://docs.nexthink.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '/',
|
||||
screenshot: { waitForTOCScrolling: false },
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// name: 'Nexthink',
|
||||
// contentBaseURL: 'https://docs.nexthink.com',
|
||||
// tests: [
|
||||
// {
|
||||
// name: 'Home',
|
||||
// url: '/',
|
||||
// screenshot: { waitForTOCScrolling: false },
|
||||
// run: waitForCookiesDialog,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||
@@ -157,11 +157,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://wiki.redmodding.org',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.cherry-ai.com',
|
||||
contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.cherry-ai.com',
|
||||
// contentBaseURL: 'https://docs.cherry-ai.com',
|
||||
// tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
||||
// },
|
||||
{
|
||||
name: 'docs.snyk.io',
|
||||
contentBaseURL: 'https://docs.snyk.io',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationDepth,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationThemeMode,
|
||||
} from '@gitbook/api';
|
||||
import { expect } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
VISITOR_TOKEN_COOKIE,
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
} from '@/lib/visitor-token';
|
||||
} from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
@@ -111,24 +113,24 @@ const testCases: TestsCase[] = [
|
||||
name: 'Customized variant titles are displayed',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = page
|
||||
const spaceDropdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="space-dropdown-button"] + div'
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// the customized space title
|
||||
await expect(
|
||||
variantSelectionDropdown.getByRole('link', {
|
||||
variantSelectionDropdown.getByRole('menuitem', {
|
||||
name: 'Multi-Variants',
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
// the NON-customized space title
|
||||
await expect(
|
||||
variantSelectionDropdown.getByRole('link', {
|
||||
variantSelectionDropdown.getByRole('menuitem', {
|
||||
name: 'RFCs',
|
||||
})
|
||||
).toBeVisible();
|
||||
@@ -145,14 +147,17 @@ const testCases: TestsCase[] = [
|
||||
url: 'api-multi-versions/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
// Click the second variant in the dropdown
|
||||
await page
|
||||
.getByRole('link', {
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: '2.0',
|
||||
})
|
||||
.click();
|
||||
@@ -168,14 +173,18 @@ const testCases: TestsCase[] = [
|
||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
await page
|
||||
.getByRole('link', {
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: '2.0',
|
||||
})
|
||||
.click();
|
||||
@@ -205,14 +214,18 @@ const testCases: TestsCase[] = [
|
||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||
},
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page
|
||||
const spaceDropdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
await spaceDropdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
'css=[data-testid="dropdown-menu"]'
|
||||
);
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
await page
|
||||
.getByRole('link', {
|
||||
await variantSelectionDropdown
|
||||
.getByRole('menuitem', {
|
||||
name: '2.0',
|
||||
})
|
||||
.click();
|
||||
@@ -258,7 +271,7 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'GitBook',
|
||||
contentBaseURL: 'https://docs.gitbook.com',
|
||||
contentBaseURL: 'https://gitbook.com/docs/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
@@ -419,6 +432,38 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'llms.txt',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'llms.txt',
|
||||
url: 'llms.txt',
|
||||
screenshot: false,
|
||||
run: async (_page, response) => {
|
||||
expect(response?.status()).toBe(200);
|
||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'llms-full.txt',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'llms-full.txt',
|
||||
url: 'llms-full.txt',
|
||||
screenshot: false,
|
||||
run: async (_page, response) => {
|
||||
expect(response?.status()).toBe(200);
|
||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site subdirectory (proxy)',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
@@ -637,6 +682,16 @@ const testCases: TestsCase[] = [
|
||||
url: 'page-options/page-with-cover',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With cover for dark mode',
|
||||
url: `page-options/page-with-dark-cover${getCustomizationURL({
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Dark,
|
||||
toggeable: false,
|
||||
},
|
||||
})}`,
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With hero cover',
|
||||
url: 'page-options/page-with-hero-cover',
|
||||
@@ -806,6 +861,16 @@ const testCases: TestsCase[] = [
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: `With flat and circular corners - Theme mode ${themeMode}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
depth: CustomizationDepth.Flat,
|
||||
corners: CustomizationCorners.Circular,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationDefaultFont,
|
||||
CustomizationDepth,
|
||||
type CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
@@ -168,6 +169,7 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
new URL(testEntryPathname, testCase.contentBaseURL).toString()
|
||||
)
|
||||
: getTestURL(testEntryPathname);
|
||||
|
||||
if (testEntry.cookies) {
|
||||
await context.addCookies(
|
||||
testEntry.cookies.map((cookie) => ({
|
||||
@@ -219,10 +221,10 @@ export function runTestCases(testCases: TestsCase[]) {
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
beforeScreenshot: async ({ runStabilization }) => {
|
||||
await runStabilization();
|
||||
await waitForIcons(page);
|
||||
if (screenshotOptions?.waitForTOCScrolling !== false) {
|
||||
await waitForTOCScrolling(page);
|
||||
}
|
||||
await waitForIcons(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -277,6 +279,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
dangerColor: { light: '#FB2C36', dark: '#FB2C36' },
|
||||
successColor: { light: '#00C950', dark: '#00C950' },
|
||||
corners: CustomizationCorners.Rounded,
|
||||
depth: CustomizationDepth.Subtle,
|
||||
font: CustomizationDefaultFont.Inter,
|
||||
background: CustomizationBackground.Plain,
|
||||
icons: CustomizationIconsStyle.Regular,
|
||||
@@ -345,61 +348,78 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
*/
|
||||
async function waitForIcons(page: Page) {
|
||||
await page.waitForFunction(() => {
|
||||
const urls = new Set<string>();
|
||||
const urlStates: Record<
|
||||
string,
|
||||
{ state: 'pending'; uri: null } | { state: 'loaded'; uri: string }
|
||||
> = (window as any).__ICONS_STATES__ || {};
|
||||
(window as any).__ICONS_STATES__ = urlStates;
|
||||
|
||||
const fetchSvgAsDataUri = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch SVG: ${response.status}`);
|
||||
}
|
||||
|
||||
const svgText = await response.text();
|
||||
const encoded = encodeURIComponent(svgText).replace(/'/g, '%27').replace(/"/g, '%22');
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encoded}`;
|
||||
};
|
||||
|
||||
const loadUrl = (url: string) => {
|
||||
// Mark the URL as pending.
|
||||
urlStates[url] = { state: 'pending', uri: null };
|
||||
fetchSvgAsDataUri(url).then((uri) => {
|
||||
urlStates[url] = { state: 'loaded', uri };
|
||||
});
|
||||
};
|
||||
|
||||
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
|
||||
const results = icons.map((icon) => {
|
||||
if (!(icon instanceof SVGElement)) {
|
||||
throw new Error('Icon is not an SVGElement');
|
||||
}
|
||||
|
||||
// If loaded, good it passes the test.
|
||||
if (icon.dataset.loadingState === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not loaded yet, we need to load it.
|
||||
if (icon.dataset.loadingState === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore icons that are not visible.
|
||||
if (!icon.checkVisibility()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const state = icon.getAttribute('data-argos-state');
|
||||
|
||||
if (state === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state === 'loaded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
const url = urlMatch?.[1];
|
||||
|
||||
// If URL is invalid we throw an error.
|
||||
if (!url) {
|
||||
throw new Error('No mask-image');
|
||||
}
|
||||
|
||||
// If the URL is already loaded, we just mark it as loaded.
|
||||
if (urls.has(url)) {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
return true;
|
||||
// If the URL is already queued for loading, we return the state.
|
||||
if (urlStates[url]) {
|
||||
if (urlStates[url].state === 'loaded') {
|
||||
icon.setAttribute('data-argos-state', 'pending');
|
||||
icon.style.maskImage = `url("${urlStates[url].uri}")`;
|
||||
requestAnimationFrame(() => {
|
||||
icon.setAttribute('data-argos-state', 'loaded');
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark the icon as pending and load the image.
|
||||
icon.dataset.loadingState = 'pending';
|
||||
|
||||
// Mark the URL as seen.
|
||||
urls.add(url);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.decode().then(() => {
|
||||
// Wait two frames to let the time to the icon to repaint.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
icon.dataset.loadingState = 'loaded';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
loadUrl(url);
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/api": "^0.119.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@gitbook/colors": "workspace:*",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
@@ -37,6 +38,7 @@
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"event-iterator": "^2.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
@@ -44,16 +46,24 @@
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-frontmatter": "^2.0.1",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"unist-util-remove": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"memoizee": "^0.4.17",
|
||||
"next": "14.2.26",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.0",
|
||||
"p-map": "^7.0.3",
|
||||
"parse-cache-control": "^1.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"partial-json": "^0.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -70,13 +80,11 @@
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"zod": "^3.24.2",
|
||||
"zod-to-json-schema": "^3.24.5",
|
||||
"event-iterator": "^2.0.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^4.3.0",
|
||||
"@cloudflare/next-on-pages": "1.13.7",
|
||||
"@argos-ci/playwright": "^5.0.3",
|
||||
"@cloudflare/next-on-pages": "1.13.12",
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# GitBook immutable static assets
|
||||
# Duplicated from next.config.mjs until OpenNext supports generating static headers
|
||||
/~gitbook/static/*
|
||||
cache-control: public,max-age=31536000,immutable
|
||||
Access-Control-Allow-Origin: *
|
||||
/_next/static/*
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import * as storage from '@/lib/local-storage';
|
||||
import type { ResolvedContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -18,6 +19,8 @@ export function AnnouncementBanner(props: {
|
||||
}) {
|
||||
const { announcement, contentRef } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
|
||||
const hasLink = announcement.link && contentRef?.href;
|
||||
const closeable = announcement.style !== 'danger';
|
||||
|
||||
@@ -25,13 +28,13 @@ export function AnnouncementBanner(props: {
|
||||
const style = BANNER_STYLES[announcement.style];
|
||||
|
||||
return (
|
||||
<div className="announcement-banner theme-bold:bg-header-background pt-4 pb-2">
|
||||
<div id="announcement-banner" className="theme-bold:bg-header-background pt-4 pb-2">
|
||||
<div className="scroll-nojump">
|
||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||
<Tag
|
||||
href={contentRef?.href ?? ''}
|
||||
className={tcls(
|
||||
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
|
||||
'flex w-full items-start justify-center overflow-hidden circular-corners:rounded-xl rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
|
||||
style.container,
|
||||
closeable && 'pr-12',
|
||||
hasLink && style.hover
|
||||
@@ -78,9 +81,10 @@ export function AnnouncementBanner(props: {
|
||||
</Tag>
|
||||
{closeable ? (
|
||||
<button
|
||||
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
|
||||
className={`absolute top-0 right-4 mt-2 mr-2 rounded circular-corners:rounded-lg straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
|
||||
type="button"
|
||||
onClick={dismissAnnouncement}
|
||||
title={tString(language, 'close')}
|
||||
>
|
||||
<Icon icon="close" className="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Button } from '../primitives';
|
||||
import { AskInput } from './AskInput';
|
||||
import { AskMessages } from './AskMessages';
|
||||
import { useAskController, useAskState } from './state';
|
||||
|
||||
export function AskDialog() {
|
||||
const state = useAskState();
|
||||
const controller = useAskController();
|
||||
|
||||
if (!state.opened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
className={`ask-dialog ${tcls(
|
||||
'z-40',
|
||||
'fixed',
|
||||
'top-3',
|
||||
'right-3',
|
||||
'bottom-3',
|
||||
'w-[480px]',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'bg-tint-base',
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
'circular-corners:rounded-2xl',
|
||||
'ring-1',
|
||||
'ring-tint',
|
||||
'shadow-2xl',
|
||||
'depth-flat:shadow-none',
|
||||
'overflow-hidden',
|
||||
'dark:ring-inset',
|
||||
'dark:ring-tint'
|
||||
)})`}
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<div className="flex-1"></div>
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="blank"
|
||||
icon="close"
|
||||
iconOnly
|
||||
onClick={() => {
|
||||
controller.close();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AskMessages session={state.session} />
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<AskInput />
|
||||
</div>
|
||||
{/* <div>
|
||||
<h1>{state.session.title}</h1>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import React from 'react';
|
||||
import { useAskController } from './state';
|
||||
|
||||
export function AskInput() {
|
||||
const controller = useAskController();
|
||||
const [value, setValue] = React.useState('');
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={tcls('resize-none', 'bg-tint-base', 'rounded-lg', 'm-2', 'p-2', 'flex-1')}
|
||||
value={value}
|
||||
placeholder="Ask a question..."
|
||||
onChange={(event) => {
|
||||
setValue(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
controller.postMessage({
|
||||
message: value,
|
||||
});
|
||||
setValue('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AskSession } from './state';
|
||||
|
||||
export function AskMessages(props: {
|
||||
session: AskSession;
|
||||
}) {
|
||||
const { session } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{session.messages.map((message, index) => {
|
||||
return <div key={index}>{message.content}</div>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './AskDialog';
|
||||
export * from './state';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AIMessage } from '@gitbook/api';
|
||||
import { DocumentView } from '../../DocumentView';
|
||||
|
||||
/**
|
||||
* Render a message from the API backend.
|
||||
*/
|
||||
export function AIMessageView(props: {
|
||||
message: AIMessage;
|
||||
}) {
|
||||
const { message } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{message.steps.map((step, index) => {
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-2">
|
||||
<DocumentView
|
||||
document={step.content}
|
||||
context={{
|
||||
mode: 'default',
|
||||
contentContext: undefined,
|
||||
wrapBlocksInSuspense: false,
|
||||
}}
|
||||
style={['space-y-5']}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user