Compare commits

..

3 Commits

Author SHA1 Message Date
taranvohra b1de0adb6a get rid of Object.values 2024-09-24 19:17:05 +05:30
taranvohra 408540ae74 Merge remote-tracking branch 'origin/main' into fix-spaces-list 2024-09-24 19:15:39 +05:30
taranvohra cb9a15339d Fix spaces list of a site 2024-08-28 16:05:44 +05:30
468 changed files with 10702 additions and 85031 deletions
-1
View File
@@ -1 +0,0 @@
1.2.1
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Avoid multiple <h1> in the page by using a <div> for the title in the header
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Adds content-visibility css property to OpenAPI Operation for better render performance
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': minor
---
Add Python support in Code Samples
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
RND-3532: drop down menu for hidden links at small screen size
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Change dark mode shadow for multi-space search toolbar
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Use ellipsis and fix icon color for more links in the header on small screen
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/icons': patch
---
Fix first run of the package in development mode
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
bump: scalar from 1.0.5 to 1.0.7
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/cache-do': minor
---
First version of a new cache backend powered by Cloudflare Durable Objects
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Add support for Norwegian language
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Add support for page icons
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Only display spaces dropdown in compact header when site is multi-variants
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/icons': minor
---
Initial release for `@gitbook/icons`, a package of content icons to be used for pages, documents, etc.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Persist state of tabs and dynamically sync them based on title
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Ensure "Sponsored via GitBook" can be translated in all languages
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Use url hash to open Expandable and scroll to anchor
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix margin for paragraphs in quote blocks
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': minor
---
Add required query parameters to the code sample
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': minor
---
Display pattern if available in parmas in OpenAPI block
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix inconsistent click area in table because of scroll indicator
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': minor
'gitbook': patch
---
Synchronize response and response example tabs
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Show definition title when visible in cards
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': minor
'gitbook': patch
---
Rollback of scalar modal while fixing perf issue
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Optimize images rendered in community ads
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-contentkit': patch
---
Fix rendering of webframe with SSR causing wrong communication between frame and renderer
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fix crash when `example` is undefined for a response
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Add support for icons style customization for sites
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Adds tabs to Response Example section e.g. for status code examples
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Render mandatory headers in code sample
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Update Scalar client to latest version
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Prevent search indexation for pages where it's configured as disabled
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix table of contents being visible on mobile when disabled at the page level
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Update rendering of community ads to match new API response, and make it possible to preview ads.
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-math': minor
---
Export binary `gitbook-math` to copy assets to a directory
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix "ip" being passed to BSA for community ads
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/icons': minor
---
Fix bin gitbook-icons when installed without the Pro token
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Don't show hidden pages in the empty state of a page
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix linking to a tab itself
+1 -1
View File
@@ -12,7 +12,7 @@ runs:
# Cache browser binaries, cache key is based on Playwright version and OS
- name: 🧰 Cache Playwright browser binaries
uses: actions/cache@v4
uses: actions/cache@v3
id: playwright-cache
with:
path: '~/.cache/ms-playwright'
-18
View File
@@ -1,18 +0,0 @@
name: 'Setup Bun'
description: 'Install Bun and cache dependencies'
runs:
using: 'composite'
steps:
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
# and impacting bandwidth there.
- name: Cache bun
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
+59 -228
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
jobs:
deploy:
name: Deploy to Cloudflare Pages
@@ -13,22 +13,29 @@ jobs:
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
deployment_url: ${{ steps.cloudflare.outputs.url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
@@ -39,198 +46,33 @@ jobs:
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build Next.js with next-on-pages
run: bun run turbo gitbook#build:cloudflare
run: bun run build:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
- id: cloudflare
name: Publish to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
directory: ./packages/gitbook/.vercel/output/static
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
workingDirectory: ./
wranglerVersion: '3.82.0'
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
echo "ID: ${{ steps.cloudflare.outputs.id }}"
echo "URL: ${{ steps.cloudflare.outputs.url }}"
echo "Environment: ${{ steps.cloudflare.outputs.environment }}"
echo "Alias: ${{ steps.cloudflare.outputs.alias }}"
- name: Archive build output
uses: actions/upload-artifact@v4
with:
name: build-output
path: .vercel/
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
- name: Update Deployment Status to Success
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
deploy-v2-vercel:
name: Deploy v2 to Vercel
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "VERCEL_ENVIRONMENT=production" >> $GITHUB_ENV
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_URL=https://open-2v.gitbook.com" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static-2v.gitbook.com" >> $GITHUB_ENV
if: startsWith(github.ref, 'refs/heads/main')
- name: Sets env vars for preview
run: |
echo "VERCEL_ENVIRONMENT=preview" >> $GITHUB_ENV
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Pull Vercel Environment Information
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: bun run vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
run: |
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=$VERCEL_ENVIRONMENT $([ "$VERCEL_ENVIRONMENT" = "production" ] && echo "--prod") --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_URL=https://open-2c.gitbook.com" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static-2c.gitbook.com" >> $GITHUB_ENV
if: startsWith(github.ref, 'refs/heads/main')
- name: Sets env vars for preview
run: |
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build worker
run: bun run turbo build:v2:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ./
wranglerVersion: '3.82.0'
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy
- deploy-v2-vercel
- deploy-v2-cloudflare
steps:
- name: Find GitHub Comment
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Summary of the deployments:
### Version 1 (production)
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
### Version 2 (experimental)
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | v1 | v2 |
| --- | --- | --- |
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
name: Visual Testing
@@ -238,8 +80,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
@@ -247,7 +91,7 @@ jobs:
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
@@ -255,31 +99,6 @@ jobs:
name: playwright-test-results
path: packages/gitbook/test-results/
retention-days: 3
visual-testing-customers:
runs-on: ubuntu-latest
name: Visual Testing Customers
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers'
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results-customers
path: packages/gitbook/test-results/
retention-days: 3
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
@@ -287,8 +106,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -296,7 +117,7 @@ jobs:
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
@@ -304,8 +125,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -317,8 +140,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -330,8 +155,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -342,14 +169,16 @@ jobs:
runs-on: ubuntu-latest
name: Build (Open Source)
env:
NPM_TOKEN_READONLY: ''
NPMRC_FONT_AWESOME_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build
@@ -359,8 +188,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+8 -4
View File
@@ -17,8 +17,10 @@ jobs:
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -43,8 +45,10 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
-3
View File
@@ -22,6 +22,3 @@ yarn-error.log*
# Env files
.env.local
# TypeScript
*.tsbuildinfo
-7
View File
@@ -1,6 +1,5 @@
.next
.vercel
.open-next
# Generated
packages/emoji-codepoints/index.ts
@@ -9,9 +8,3 @@ packages/icons/src/data/*.json
# Build files
dist/
README.md
CHANGELOG.md
# v2
packages/gitbook-v2/public/~gitbook/static/
+3 -45
View File
@@ -5,7 +5,7 @@
</p>
<p align="center">
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjUiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCA2NSA2NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI3LjM5NjQgMzMuNjc2NEMzMC41MjU1IDM1LjQ4MjQgMzIuMDkgMzYuMzg1NCAzMy44MDgzIDM2LjM4NjlDMzUuNTI2NSAzNi4zODg0IDM3LjA5MjYgMzUuNDg4MiA0MC4yMjQ5IDMzLjY4NzdMNjAuMTkxNCAyMi4yMTA0QzYxLjA5MjcgMjEuNjkyMiA2MS42NDg0IDIwLjczMTggNjEuNjQ4NCAxOS42OTIxQzYxLjY0ODQgMTguNjUyNCA2MS4wOTI3IDE3LjY5MiA2MC4xOTE0IDE3LjE3MzlMNDAuMjE3NyA1LjY5MjQ1QzM3LjA4ODggMy44OTM4NiAzNS41MjQzIDIuOTk0NTcgMzMuODA3OCAyLjk5NTI0QzMyLjA5MTIgMi45OTU5MSAzMC41Mjc1IDMuODk2NDIgMjcuNCA1LjY5NzQ0TDEwLjIyOTMgMTUuNTg1NUMxMC4xMDIgMTUuNjU4OCAxMC4wMzg0IDE1LjY5NTQgOS45NzkwOCAxNS43MzAxQzQuMTEzNzEgMTkuMTYzNyAwLjQ4OTg5MiAyNS40MzIzIDAuNDQxNDM4IDMyLjIyODZDMC40NDA5NDggMzIuMjk3MyAwLjQ0MDk0OCAzMi4zNzA4IDAuNDQwOTQ4IDMyLjUxNzZDMC40NDA5NDggMzIuNjY0MyAwLjQ0MDk0OCAzMi43Mzc2IDAuNDQxNDM3IDMyLjgwNjNDMC40ODk3ODUgMzkuNTk0OSA0LjEwNTUyIDQ1Ljg1NzcgOS45NjA0NCA0OS4yOTRDMTAuMDE5NiA0OS4zMjg3IDEwLjA4MzIgNDkuMzY1NCAxMC4yMTAyIDQ5LjQzODdMMjAuOTY1OSA1NS42NDg3QzI3LjIzMzIgNTkuMjY3MyAzMC4zNjY4IDYxLjA3NjYgMzMuODA4MSA2MS4wNzc3QzM3LjI0OTMgNjEuMDc4OSA0MC4zODQyIDU5LjI3MTcgNDYuNjUzOSA1NS42NTc0TDU4LjAwOCA0OS4xMTIxQzYxLjE0NzQgNDcuMzAyMyA2Mi43MTcxIDQ2LjM5NzQgNjMuNTc5IDQ0LjkwNTZDNjQuNDQwOSA0My40MTM5IDY0LjQ0MDkgNDEuNjAyIDY0LjQ0MDkgMzcuOTc4NFYzMC45NzgxQzY0LjQ0MDkgMjkuOTcyOCA2My44OTY1IDI5LjA0NjQgNjMuMDE4MiAyOC41NTczQzYyLjE2ODQgMjguMDgzOSA2MS4xMzI1IDI4LjA5MSA2MC4yODkxIDI4LjU3NThMMzcuMDA3NCA0MS45NTg4QzM1LjQ0NTQgNDIuODU2NyAzNC42NjQzIDQzLjMwNTYgMzMuODA3MyA0My4zMDU5QzMyLjk1MDIgNDMuMzA2MiAzMi4xNjg5IDQyLjg1NzcgMzAuNjA2MyA0MS45NjA3TDE0Ljg0ODcgMzIuOTE1NUMxNC4wNTk0IDMyLjQ2MjQgMTMuNjY0NyAzMi4yMzU5IDEzLjM0NzcgMzIuMTk1QzEyLjYyNSAzMi4xMDE3IDExLjkzMDEgMzIuNTA2NiAxMS42NTQ4IDMzLjE4MTNDMTEuNTM0MSAzMy40NzcyIDExLjUzNjUgMzMuOTMyMiAxMS41NDE0IDM0Ljg0MjRDMTEuNTQ1IDM1LjUxMjQgMTEuNTQ2OCAzNS44NDc0IDExLjYwOTQgMzYuMTU1NkMxMS43NDk3IDM2Ljg0NTYgMTIuMTEyNyAzNy40NzA1IDEyLjY0MjggMzcuOTM0MUMxMi44Nzk1IDM4LjE0MTEgMTMuMTY5NiAzOC4zMDg1IDEzLjc1IDM4LjY0MzVMMzAuNTk3NCA0OC4zNjcyQzMyLjE2NDEgNDkuMjcxNCAzMi45NDc0IDQ5LjcyMzUgMzMuODA3NSA0OS43MjM3QzM0LjY2NzcgNDkuNzIzOSAzNS40NTEzIDQ5LjI3MjMgMzcuMDE4NCA0OC4zNjg5TDU3LjY2ODQgMzYuNDY1NEM1OC4yMDM3IDM2LjE1NjkgNTguNDcxNCAzNi4wMDI2IDU4LjY3MjEgMzYuMTE4NUM1OC44NzI3IDM2LjIzNDUgNTguODcyNyAzNi41NDM0IDU4Ljg3MjcgMzcuMTYxM1Y0MC4zMzY1QzU4Ljg3MjcgNDEuMjQyNCA1OC44NzI3IDQxLjY5NTQgNTguNjU3MiA0Mi4wNjgzQzU4LjQ0MTggNDIuNDQxMyA1OC4wNDkzIDQyLjY2NzUgNTcuMjY0NCA0My4xMTk5TDQwLjIzMjIgNTIuOTM4QzM3LjA5NjYgNTQuNzQ1NCAzNS41Mjg4IDU1LjY0OTIgMzMuODA3OSA1NS42NDg0QzMyLjA4NjkgNTUuNjQ3NiAzMC41MTk5IDU0Ljc0MjQgMjcuMzg2IDUyLjkzMjFMMTEuNDUwOSA0My43MjdDMTEuNDAwMyA0My42OTc4IDExLjM3NSA0My42ODMyIDExLjM1MTQgNDMuNjY5NEM4LjAxMDIzIDQxLjcxNyA1Ljk0ODU5IDM4LjE0NTEgNS45MjkyNSAzNC4yNzU0QzUuOTI5MTIgMzQuMjQ4IDUuOTI5MTIgMzQuMjE4OCA1LjkyOTEyIDM0LjE2MDRWMzEuMjQ1OEM1LjkyOTEyIDI5LjEwOTUgNy4wNjY4OSAyNy4xMzQ5IDguOTE1MTMgMjYuMDYzNkMxMC41NDgzIDI1LjExNjkgMTIuNTYyOCAyNS4xMTUxIDE0LjE5NzcgMjYuMDU4N0wyNy4zOTY0IDMzLjY3NjRaIiBmaWxsPSIjRjJGN0Y3Ii8+Cjwvc3ZnPgo=&labelColor=5c5c5c&color=3F89A1&label=%20" alt="Documented on GitBook"></a>
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
@@ -35,10 +35,8 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >=20.6)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
- Node.js (Version: >=18.x)
- Bun
### Set up
@@ -80,26 +78,6 @@ Any published GitBook site can be accessed through your local development instan
All pull-requests will be tested against both visual and performances testing to prevent regressions.
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
@@ -141,26 +119,6 @@ If you plan to distribute the code, you must make the source code public to comp
See `LICENSE` for more information.
## Badges
<p align="left">
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=F4E28D"></a>
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=FDA599"></a>
</p>
```md
[![GitBook](https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1)](https://gitbook.com/)
```
```html
<a href="https://gitbook.com">
<img
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
/>
</a>
```
## Acknowledgements
GitBook wouldn't be possible without these projects:
-5871
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes]
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
+8 -18
View File
@@ -1,25 +1,12 @@
{
"name": "gitbook",
"version": "0.1.0",
"devDependencies": {
"@changesets/cli": "^2.27.12",
"prettier": "^3.0.3",
"turbo": "^2.4.1-canary.2",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.1.18",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"private": true,
"packageManager": "bun@1.1.18",
"scripts": {
"dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build",
"build:v2": "turbo run build:v2",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
@@ -27,7 +14,6 @@
"format:check": "prettier ./ --ignore-unknown --list-different",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
@@ -37,8 +23,12 @@
"workspaces": [
"packages/*"
],
"devDependencies": {
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.1.2"
},
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
"@vercel/next@4.3.6": "patches/@vercel%2Fnext@4.3.6.patch"
}
}
-18
View File
@@ -1,18 +0,0 @@
# @gitbook/cache-do
## 0.1.1
### Patch Changes
- b7a5106: Disable cloudflare observability in production
## 0.1.0
### Minor Changes
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
### Patch Changes
- 56f5fa1: Enable Workers observability with a sampling of 0.1
+2 -2
View File
@@ -14,14 +14,14 @@
"default": "./dist/api.js"
}
},
"version": "0.1.1",
"version": "0.0.0",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^3.109.2"
"wrangler": "^3.78.7"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
+82 -107
View File
@@ -53,20 +53,16 @@ export class CacheObject extends DurableObject {
* Get the value of a property.
*/
public async get<Value = unknown>(key: string) {
return this.logOperation({ operation: 'get', key }, async (setLog) => {
return timeFn(`get: ${key}`, async () => {
// Try the memory state first.
const memoryEntry = this.lru.get(key);
if (memoryEntry) {
setLog({ memory: true });
setLog({ memoryMatch: !!memoryEntry.match });
console.log(`get: (memory, ${!!memoryEntry.match}) ${key}`);
if (!memoryEntry.match) {
return;
}
const isExpired = memoryEntry.match.expiresAt < Date.now();
setLog({ memoryExpired: isExpired });
if (!isExpired) {
if (memoryEntry.match.expiresAt > Date.now()) {
return memoryEntry.match.value as Value;
}
}
@@ -79,31 +75,28 @@ export class CacheObject extends DurableObject {
* Get the value of a property from the DO storage.
*/
public async getFromStorage<Value = unknown>(key: string) {
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
const entries = await this.ctx.storage.list<Uint8Array>({
prefix: getStoragePropKey(key),
noCache: true,
});
if (entries.size) {
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
if (entry && entry.value.expiresAt > Date.now()) {
// Found
this.lru.set(key, { match: entry.value });
return entry.value.value;
}
}
// Not found
this.lru.set(key, { match: undefined });
const entries = await this.ctx.storage.list<Uint8Array>({
prefix: getStoragePropKey(key),
noCache: true,
});
if (entries.size) {
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
if (entry && entry.expiresAt > Date.now()) {
// Found
this.lru.set(key, { match: entry });
return entry.value;
}
}
// Not found
this.lru.set(key, { match: undefined });
}
/**
* Set a value in the cache object.
*/
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return this.logOperation({ operation: 'set', key }, async (setLog) => {
return timeFn(`set: ${key}`, async () => {
const prop: CacheObjectProp<Value> = {
value,
expiresAt,
@@ -112,12 +105,10 @@ export class CacheObject extends DurableObject {
this.lru.set(key, { match: prop });
await this.ctx.storage.transaction(async (tx) => {
const entries = encodeChunks(key, prop);
const chunks = Object.keys(entries).length;
setLog({ chunks });
const clockValue: CacheObjectExp = {
k: key,
c: chunks,
c: Object.keys(entries).length,
};
await tx.put(getGCClockKey(key, expiresAt), clockValue);
@@ -136,101 +127,76 @@ export class CacheObject extends DurableObject {
* Purge all keys in the cache object.
*/
public async purge() {
return this.logOperation({ operation: 'purge' }, async (setLog) => {
let result = new Set<string>();
let result = new Set<string>();
try {
// List all the keys in the cache object.
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
entries.forEach((exp) => {
result.add(exp.k);
});
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during purge, resetting the cache object', error);
}
try {
// List all the keys in the cache object.
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
console.log(`purge: ${entries.size} entries`);
entries.forEach((exp) => {
result.add(exp.k);
});
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during purge, resetting the cache object', error);
}
await this.reset();
return Array.from(result);
});
await this.reset();
console.log(`purge returns`, Array.from(result));
return Array.from(result);
}
/**
* Alarm to garbage collect all entries that have expired.
*/
async alarm() {
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
try {
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
setLog({ entries: entries.size });
const toDeleteSet = new Set<string>();
try {
const entries = await this.ctx.storage.list<CacheObjectExp>({
prefix: 'exp.',
noCache: true,
});
const toDeleteSet = new Set<string>();
for (const [key, exp] of entries) {
const timestamp = parseInt(key.split('.')[1]);
if (timestamp < Date.now()) {
toDeleteSet.add(key);
for (let i = 0; i < exp.c; i++) {
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
}
for (const [key, exp] of entries) {
const timestamp = parseInt(key.split('.')[1]);
if (timestamp < Date.now()) {
toDeleteSet.add(key);
for (let i = 0; i < exp.c; i++) {
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
}
}
// Delete the keys by batch of 128.
const toDelete = Array.from(toDeleteSet);
setLog({ toDelete: toDelete.length });
for (let i = 0; i < toDelete.length; i += 128) {
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
}
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
if (toDelete.length) {
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during alarm, reset the cache object', error);
await this.reset();
}
});
// Delete the keys by batch of 128.
const toDelete = Array.from(toDeleteSet);
for (let i = 0; i < toDelete.length; i += 128) {
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
}
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
if (toDelete.length) {
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
} catch (error) {
// If an error occurs, reset the cache object.
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
console.error('Error during alarm, reset the cache object', error);
await this.reset();
}
}
/**
* Reset the cache object.
*/
async reset() {
return this.logOperation({ operation: 'reset' }, async () => {
this.lru.clear();
await this.ctx.storage.deleteAll();
});
}
/**
* Time and log an operation.
*/
async logOperation<T>(
log: Record<string, unknown>,
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>,
): Promise<T> {
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
let update: Record<string, unknown> = {};
const start = performance.now();
try {
return await fn((arg) => {
Object.assign(update, arg);
});
} finally {
const duration = performance.now() - start;
console.log({ ...log, ...update, objectId, duration });
}
console.log('reset: clear all entries');
this.lru.clear();
await this.ctx.storage.deleteAll();
}
}
@@ -262,7 +228,7 @@ function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
return entries;
}
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
function decodeChunks<T>(entries: Map<string, Uint8Array>): T | undefined {
const chunks = Array.from(entries.entries())
.map(([key, value]) => {
const index = parseInt(key.split('.').pop()!);
@@ -276,7 +242,7 @@ function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: nu
}
const buf = mergeUint8Array(chunks);
return { value: decode(buf) as T, size: buf.length };
return decode(buf) as T;
}
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
@@ -297,3 +263,12 @@ function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
}
return result;
}
async function timeFn<T>(message: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
console.log(`${message} (${performance.now() - start}ms)`);
}
}
+21 -27
View File
@@ -22,7 +22,7 @@ const doLocationHints: {
* Client to access a cache tag.
*/
export class CacheObjectStub {
private stub: DurableObjectStub<CacheObject>;
private opened: CacheObjectDescriptor | null = null;
constructor(
/** Binding to the CacheObject durable object */
@@ -31,42 +31,41 @@ export class CacheObjectStub {
private locationId: CacheLocationId,
/** Name of the tag */
private tag: string,
) {
const groupId = getCacheObjectIdName(this.locationId, this.tag);
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
}
) {}
/**
* Open a descriptor to the cache object.
* It can be used to perform multiple operations in a single RPC session.
* Ex:
* ```ts
* using desc = cache.open();
* await desc.set('key', 'value', Date.now() + 1000);
* await desc.get('key');
* ```
* Open the cache object.
*/
async open() {
return await this.stub.open();
if (!this.opened) {
const groupId = getCacheObjectIdName(this.locationId, this.tag);
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
this.opened = await cacheGroup.open();
}
return this.opened;
}
/**
* Get a value from the cache.
*/
async get<Value = unknown>(key: string) {
return (await this.stub.get(key)) as Value | undefined;
const desc = await this.open();
return await desc.get<Value>(key);
}
/**
* Set a value in the cache.
*/
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return await this.stub.set(key, value, expiresAt);
// TODO: Should we write on all locations instead of just the current one?
const desc = await this.open();
return await desc.set<Value>(key, value, expiresAt);
}
/**
@@ -77,12 +76,7 @@ export class CacheObjectStub {
await Promise.all(
allLocations.map(async (locationId) => {
const groupId = getCacheObjectIdName(locationId, this.tag);
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
// Initialize the object with a locaiton hint,
// as we might want to purge all locations before the object is created.
// https://developers.cloudflare.com/durable-objects/reference/data-location/
locationHint: doLocationHints[this.locationId],
});
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId));
const locationkeys = await cacheGroup.purge();
locationkeys.forEach((key) => keys.add(key));
}),
-10
View File
@@ -10,18 +10,8 @@ migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[observability]
enabled = false
[env.preview]
name = "gitbook-open-cache-preview"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
migrations = [
{tag = "v1", new_classes = ["CacheObject"]}
]
[env.preview.observability]
enabled = true
head_sampling_rate = 0.1
-1
View File
@@ -1 +0,0 @@
dist/
-7
View File
@@ -1,7 +0,0 @@
# @gitbook/colors
## 0.2.0
### Minor Changes
- 445baaa: Initial release
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/colors`
A set of default colors and transformation functions used throughout the GitBook Open and app.
-26
View File
@@ -1,26 +0,0 @@
{
"name": "@gitbook/colors",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.2.0",
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w"
},
"files": [
"dist",
"src",
"README.md",
"CHANGELOG.md"
]
}
-39
View File
@@ -1,39 +0,0 @@
/**
* Default primary color throughout the GitBook ecosystem.
*/
export const DEFAULT_PRIMARY_COLOR = '#346DDB';
/**
* The darkest color that exists in GitBook, used as the relative minimum of every generated color scale.
*/
export const DARK_BASE = '#1D1D1D';
/**
* The lightest color that exists in GitBook, used as the relative maximum of every generated color scale.
*/
export const LIGHT_BASE = '#FFFFFF';
/**
* Used as the basis of all UI elements that are not colored by the primary color. Neutral gray by default, overridden by site customization.
*/
export const DEFAULT_TINT_COLOR = '#787878';
/**
* Used for informational messages and neutral alerts.
*/
export const DEFAULT_HINT_INFO_COLOR = '#787878';
/**
* Used for showing important information or non-critical warnings.
*/
export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00';
/**
* Used for destructive actions or raising attention to critical information.
*/
export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36';
/**
* Used for showing positive actions or achievements.
*/
export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950';
-2
View File
@@ -1,2 +0,0 @@
export * from './colors';
export * from './transformations';
-408
View File
@@ -1,408 +0,0 @@
import { DARK_BASE, LIGHT_BASE, DEFAULT_TINT_COLOR } from './colors';
type ColorShades = {
[key: string]: string;
};
type RGBColor = [number, number, number];
type OKLABColor = { L: number; A: number; B: number };
type OKLCHColor = { L: number; C: number; H: number };
const D65 = [95.047, 100.0, 108.883]; // Reference white (D65)
export enum ColorCategory {
backgrounds = 'backgrounds',
components = 'components',
borders = 'borders',
accents = 'accents',
text = 'text',
}
type ColorSubScale = {
[key: string]: number;
};
/**
* Main color scale object.
*
* Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation.
* Each subitem maps a semantic name within that category to a step in the scale.
*/
export const scale: Record<ColorCategory, ColorSubScale> = {
[ColorCategory.backgrounds]: {
/** Base background */
base: 1,
/** Accent background */
subtle: 2,
},
[ColorCategory.components]: {
/** Component background */
DEFAULT: 3,
/** Component hover background */
hover: 4,
/** Component active background */
active: 5,
},
[ColorCategory.borders]: {
/** Subtle borders, separators */
subtle: 6,
/** Element border, focus rings */
DEFAULT: 7,
/** Element hover border */
hover: 8,
},
[ColorCategory.accents]: {
/** Solid backgrounds */
solid: 9,
/** Hovered solid backgrounds */
'solid-hover': 10,
},
[ColorCategory.text]: {
/** Very low-contrast text
* Caution: this contrast does not meet accessiblity guidelines.
* Always check if you need to include a mitigating contrast-more style for users who need it. */
subtle: 9,
/** Low-contrast text */
DEFAULT: 11,
/** High-contrast text */
strong: 12,
},
};
/**
* The mix of foreground and background for every step in a colour scale.
* 0: 100% of the background color's luminosity, white in light mode
* 1: 100% of the foreground color's luminosity, black in light mode
*/
export const colorMixMapping = {
// bgs |components |borders |solid |text
light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1],
dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1],
};
/**
* Convert a hex color to an RGB color.
*/
export function hexToRgb(hex: string): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGB values separated by spaces
return `${r} ${g} ${b}`;
}
/**
* Convert a hex color to a RGBA color.
*/
export function hexToRgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgbArray(hex);
// Return the RGBA values separated by spaces
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Generate Tailwind-compatible shades from a single color
* @param {string} hex The hex code to generate shades from
* @param {boolean} halfShades Generate additional shades, e.g. at 150
* @returns {{[key: number]: string}}
*/
export function shadesOfColor(hex: string, halfShades = false) {
const baseColor = hex;
const shades = [
50,
100,
200,
300,
400,
500,
600,
700,
800,
900,
...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []),
].sort();
const result: ColorShades = {};
for (const shade of shades) {
const key = shade.toString();
if (shade === 500) {
result[key] = hex;
continue;
}
let shadeIndex = shade;
const isDarkShade = shadeIndex > 500;
if (isDarkShade) {
shadeIndex -= 500;
}
const percentage = shadeIndex / 500;
const startColor = isDarkShade ? DARK_BASE : baseColor;
const endColor = isDarkShade ? baseColor : LIGHT_BASE;
result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor));
}
return result;
}
export type ColorScaleOptions = {
/** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */
darkMode?: boolean;
/** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
background?: string;
/** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */
foreground?: string;
mix?: {
/** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */
color: string;
/** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */
ratio: number;
};
};
/**
* Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour.
* @param {string} hex The hex code to generate shades from
* @param {object} options
*/
export function colorScale(
hex: string,
{
darkMode = false,
background = darkMode ? DARK_BASE : LIGHT_BASE,
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
mix,
}: ColorScaleOptions = {},
) {
const baseColor = rgbToOklch(hexToRgbArray(hex));
const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null;
const foregroundColor = rgbToOklch(hexToRgbArray(foreground));
const backgroundColor = rgbToOklch(hexToRgbArray(background));
if (mixColor && mix?.ratio && mix.ratio > 0) {
// If defined, we mix in a (tiny) bit of the mix color with the base color.
baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio);
baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio);
baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H;
}
const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
const result = [];
for (let index = 0; index < mapping.length; index++) {
const targetL =
foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]);
if (index === 8 && !mix && Math.abs(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 + 1) * 0.05 : 1;
const shade = {
L: targetL, // Blend lightness
C: baseColor.C * chromaRatio,
H: baseColor.H, // Maintain the hue from the base color
};
const newHex = rgbArrayToHex(oklchToRgb(shade));
result.push(newHex);
}
return result;
}
/**
* Convert a hex color to an RGB color set.
*/
export function hexToRgbArray(hex: string): RGBColor {
const originalHex = hex;
let value = hex.replace('#', '');
if (hex.length === 3) value = value + value;
const r = value.substring(0, 2);
const g = value.substring(2, 4);
const b = value.substring(4, 6);
const rgb = [r, g, b].map((channel) => {
try {
const channelInt = Number.parseInt(channel, 16);
if (channelInt < 0 || channelInt > 255) throw new Error();
return channelInt;
} catch {
throw new Error(`Invalid hex color provided: ${originalHex}`);
}
});
return rgb as RGBColor;
}
/**
* Convert a RGB color set to a hex color.
*/
export function rgbArrayToHex(rgb: RGBColor): string {
return `#${rgb
.map((channel) => {
const component = channel.toString(16);
if (component.length === 1) return `0${component}`;
return component;
})
.join('')}`;
}
export function getColor(percentage: number, start: RGBColor, end: RGBColor) {
const rgb = end.map((channel, index) => {
return Math.round(channel + percentage * (start[index] - channel));
});
return rgbArrayToHex(rgb as RGBColor);
}
// Utility constants and helper functions
export function rgbToLinear(rgb: RGBColor): [number, number, number] {
return rgb.map((v) => {
const scaled = v / 255;
return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
}
export function linearToRgb(linear: [number, number, number]): RGBColor {
return linear.map((v) => {
const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return Math.round(Math.max(0, Math.min(1, scaled)) * 255);
}) as RGBColor;
}
export function rgbToOklab(rgb: RGBColor): OKLABColor {
const [r, g, b] = rgbToLinear(rgb);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const lRoot = Math.cbrt(l);
const mRoot = Math.cbrt(m);
const sRoot = Math.cbrt(s);
return {
L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
};
}
export function oklabToRgb(oklab: OKLABColor): RGBColor {
const { L, A, B } = oklab;
const lRoot = L + 0.3963377774 * A + 0.2158037573 * B;
const mRoot = L - 0.1055613458 * A - 0.0638541728 * B;
const sRoot = L - 0.0894841775 * A - 1.291485548 * B;
const l = lRoot ** 3;
const m = mRoot ** 3;
const s = sRoot ** 3;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return linearToRgb([r, g, b]);
}
export function oklabToOklch(oklab: OKLABColor): OKLCHColor {
const { L, A, B } = oklab;
const C = Math.sqrt(A ** 2 + B ** 2);
const H = (Math.atan2(B, A) * 180) / Math.PI;
return { L, C, H: H < 0 ? H + 360 : H };
}
export function oklchToOklab(oklch: OKLCHColor): OKLABColor {
const { L, C, H } = oklch;
const rad = (H * Math.PI) / 180;
return {
L,
A: C * Math.cos(rad),
B: C * Math.sin(rad),
};
}
export function rgbToOklch(rgb: RGBColor): OKLCHColor {
return oklabToOklch(rgbToOklab(rgb));
}
export function oklchToRgb(oklch: OKLCHColor): RGBColor {
return oklabToRgb(oklchToOklab(oklch));
}
export function rgbToXyz(rgb: RGBColor): [number, number, number] {
const [r, g, b] = rgbToLinear(rgb);
return [
(r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
(r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100,
(r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100,
];
}
export function xyzToLab65(xyz: [number, number, number]): {
L: number;
A: number;
B: number;
} {
const [x, y, z] = xyz.map((v, i) => {
const scaled = v / D65[i];
return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116;
});
return {
L: 116 * y - 16,
A: 500 * (x - y),
B: 200 * (y - z),
};
}
export function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } {
return xyzToLab65(rgbToXyz(rgb));
}
/*
Delta Phi Star perceptual lightness contrast by Andrew Somers:
https://github.com/Myndex/deltaphistar
*/
export const PHI = 0.5 + Math.sqrt(1.25);
export function dpsContrast(a: RGBColor, b: RGBColor) {
const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI);
const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40;
return contrast < 7.5 ? 0 : contrast;
}
export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) {
const bg = hexToRgbArray(background);
const best: { color?: RGBColor; contrast: number } = {
color: undefined,
contrast: 0,
};
for (const color of foreground) {
const c = hexToRgbArray(color);
const contrast = dpsContrast(c, bg);
if (contrast > best.contrast) {
best.color = c;
best.contrast = contrast;
}
}
return best.color ? rgbArrayToHex(best.color) : foreground[0];
}
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
-11
View File
@@ -1,11 +0,0 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
# Symbolic links
public
-7
View File
@@ -1,7 +0,0 @@
# gitbook-v2
## 0.1.0
### Minor Changes
- cfccc44: Setup structure and deployment for new version
View File
-5
View File
@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-33
View File
@@ -1,33 +0,0 @@
// @ts-check
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
useCache: true,
},
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
GITBOOK_URL: process.env.GITBOOK_URL,
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
};
export default nextConfig;
-30
View File
@@ -1,30 +0,0 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import cache from '@opennextjs/cloudflare/kvCache';
const config = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
dangerous: {
enableCacheInterception: false,
},
};
export default config;
-29
View File
@@ -1,29 +0,0 @@
{
"name": "gitbook-v2",
"version": "0.1.0",
"private": true,
"dependencies": {
"next": "canary",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "^0.96.0",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.3",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
"scripts": {
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
"dev:v2": "env-cmd --silent -f ../../.env.local next",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
@@ -1,5 +0,0 @@
import { SitePageSkeleton } from '@/components/SitePage';
export default function Loading() {
return <SitePageSkeleton />;
}
@@ -1,5 +0,0 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -1,38 +0,0 @@
import { getDynamicSiteContext, getPagePathFromParams, RouteParams } from '@v2/app/utils';
import {
generateSitePageMetadata,
generateSitePageViewport,
SitePage,
} from '@/components/SitePage';
import { Metadata, Viewport } from 'next';
type PageProps = {
params: Promise<RouteParams>;
searchParams: Promise<{ fallback?: string }>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getDynamicSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const [params, searchParams] = await Promise.all([props.params, props.searchParams]);
const context = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
fallback: !!searchParams.fallback,
});
}
@@ -1,28 +0,0 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayout } from '@/components/SiteLayout';
import { getDynamicSiteContext, RouteLayoutParams } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
export default async function SiteDynamicLayout({
params,
children,
}: {
params: Promise<RouteLayoutParams>;
children: React.ReactNode;
}) {
const context = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
@@ -1,5 +0,0 @@
import { SitePageSkeleton } from '@/components/SitePage';
export default function Loading() {
return <SitePageSkeleton />;
}
@@ -1,5 +0,0 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -1,44 +0,0 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { getSiteCacheTag } from '@v2/lib/cache';
import { getPagePathFromParams, getStaticSiteContext, RouteParams } from '@v2/app/utils';
import {
generateSitePageMetadata,
generateSitePageViewport,
SitePage,
} from '@/components/SitePage';
import { Metadata, Viewport } from 'next';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
'use cache';
const params = await props.params;
const context = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(getSiteCacheTag(context.site.id));
return <SitePage context={context} pageParams={{ pathname }} redirectOnFallback={true} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getStaticSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const context = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
redirectOnFallback: true,
});
}
@@ -1,28 +0,0 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { getSiteCacheTag } from '@v2/lib/cache';
import { getStaticSiteContext, RouteLayoutParams } from '@v2/app/utils';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { SiteLayout } from '@/components/SiteLayout';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
export default async function SiteStaticLayout({
params,
children,
}: {
params: Promise<RouteLayoutParams>;
children: React.ReactNode;
}) {
'use cache';
const context = await getStaticSiteContext(await params);
cacheTag(getSiteCacheTag(context.site.id));
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout context={context} withTracking={!GITBOOK_DISABLE_TRACKING}>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
-116
View File
@@ -1,116 +0,0 @@
import { fetchSiteContextByURL } from '@v2/lib/context';
import { createDataFetcher } from '@v2/lib/data';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_URL } from '@v2/lib/env';
import { createNoopImageResizer } from '@v2/lib/images';
import { createLinker } from '@v2/lib/links';
import { headers } from 'next/headers';
export type RouteParamMode = 'url-host' | 'url';
export type RouteLayoutParams = {
mode: string;
/** URL encoded site URL */
siteURL: string;
};
export type RouteParams = RouteLayoutParams & {
pagePath: string;
};
/**
* Get the static context when rendering statically a site.
*/
export function getStaticSiteContext(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const dataFetcher = createDataFetcher();
const linker = createLinkerFromParams(params);
const imageResizer = createNoopImageResizer();
return fetchSiteContextByURL(
{
dataFetcher,
linker,
imageResizer,
},
{
url: url.toString(),
visitorAuthToken: null,
redirectOnError: false,
},
);
}
/**
* Get the site context when rendering dynamically.
* The context will depend on the request.
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const headersSet = await headers();
const dataFetcher = createDataFetcher({
apiToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
apiEndpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
});
const linker = createLinkerFromParams(params);
const imageResizer = createNoopImageResizer();
return fetchSiteContextByURL(
{
dataFetcher,
linker,
imageResizer,
},
{
url: url.toString(),
visitorAuthToken: headersSet.get('x-gitbook-visitor-token'),
// TODO: set it only when the token comes from the cookies.
redirectOnError: true,
},
);
}
/**
* Get the decoded page path from the params.
*/
export function getPagePathFromParams(params: RouteParams) {
const decoded = decodeURIComponent(params.pagePath);
return decoded;
}
function createLinkerFromParams(params: RouteLayoutParams) {
const url = getSiteURLFromParams(params);
const mode = getModeFromParams(params.mode);
if (mode === 'url-host') {
return createLinker({
host: url.host,
pathname: '/',
});
}
const gitbookURL = new URL(GITBOOK_URL);
return createLinker({
protocol: gitbookURL.protocol,
host: gitbookURL.host,
pathname: `/url/${url.host}`,
});
}
function getSiteURLFromParams(params: RouteLayoutParams) {
const decoded = decodeURIComponent(params.siteURL);
const url = new URL('https://' + decoded);
return url;
}
function getModeFromParams(mode: string): RouteParamMode {
if (mode === 'url-host') {
return 'url-host';
}
return 'url';
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Get the cache tag for a site.
*/
export function getSiteCacheTag(siteId: string) {
return `site:${siteId}`;
}
/**
* Get the cache tag for a hostname.
*/
export function getHostnameCacheTag(hostname: string) {
return `url:${hostname}`;
}
/**
* Get the cache tag for an OpenAPI spec.
*/
export function getOpenAPISpecCacheTag(organizationId: string, slug: string) {
return `organization:${organizationId}:openapi:${slug}`;
}
/**
* Get the cache tag for a space.
*/
export function getSpaceCacheTag(spaceId: string) {
return `space:${spaceId}`;
}
/**
* Get the cache tag for a change request.
*/
export function getChangeRequestCacheTag(spaceId: string, changeRequestId: string) {
return `space:${spaceId}:change-request:${changeRequestId}`;
}
-289
View File
@@ -1,289 +0,0 @@
import {
ChangeRequest,
RevisionPage,
RevisionPageDocument,
Site,
SiteCustomizationSettings,
SiteIntegrationScript,
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
Space,
} from '@gitbook/api';
import { redirect } from 'next/navigation';
import { assert } from 'ts-essentials';
import { getSiteStructureSections } from '@/lib/sites';
import { createDataFetcher, GitBookDataFetcher } from '@v2/lib/data';
import { GitBookSpaceLinker, appendPrefixToLinker } from './links';
import { ImageResizer } from './images';
/**
* Generic context when rendering content.
*/
export type GitBookBaseContext = {
/**
* Data fetcher to fetch data from GitBook.
*/
dataFetcher: GitBookDataFetcher;
/**
* Linker to generate links in the current space.
*/
linker: GitBookSpaceLinker;
/**
* Image resizer to resize images.
*/
imageResizer: ImageResizer;
};
/**
* Any context when rendering content.
*/
export type GitBookAnyContext = GitBookSpaceContext | GitBookSiteContext | GitBookPageContext;
/**
* Context when rendering a space content.
*/
export type GitBookSpaceContext = GitBookBaseContext & {
organizationId: string;
space: Space;
changeRequest: ChangeRequest | null;
/** ID of the current revision. */
revisionId: string;
/** Pages of the space. */
pages: RevisionPage[];
/** Share key of the space. */
shareKey: string | undefined;
};
export type SiteSections = {
list: (SiteSectionGroup | SiteSection)[];
current: SiteSection;
};
/**
* Context when rendering a site.
*/
export type GitBookSiteContext = GitBookSpaceContext & {
site: Site;
/** Current site space. */
siteSpace: SiteSpace;
/** All site spaces in the current section / or entire site */
siteSpaces: SiteSpace[];
/** Sections of the site. */
sections: null | SiteSections;
/** Customizations of the site. */
customization: SiteCustomizationSettings;
/** Structure of the site. */
structure: SiteStructure;
/** Scripts to load for the site. */
scripts: SiteIntegrationScript[];
/** Visitor token used to fetch the site */
visitorAuthToken: string | null;
};
/**
* Context when rendering a page.
*/
export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
page: RevisionPageDocument;
};
/**
* Fetch the context of a site for a given URL and a base context.
*/
export async function fetchSiteContextByURL(
baseContext: GitBookBaseContext,
input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
},
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const data = await dataFetcher.getPublishedContentByUrl({
url: input.url,
visitorAuthToken: input.visitorAuthToken,
redirectOnError: input.redirectOnError,
});
if ('redirect' in data) {
redirect(data.redirect);
}
const context = await fetchSiteContextByIds(
{
...baseContext,
dataFetcher: createDataFetcher({
apiEndpoint: dataFetcher.apiEndpoint,
apiToken: data.apiToken,
}),
},
{
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
space: data.space,
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
visitorAuthToken: input.visitorAuthToken,
},
);
const siteContext = {
...context,
linker: appendPrefixToLinker(context.linker, data.basePath),
};
return siteContext;
}
/**
* Fetch a site context by IDs.
*/
export async function fetchSiteContextByIds(
baseContext: GitBookBaseContext,
ids: {
organization: string;
site: string;
siteSection: string | undefined;
siteSpace: string | undefined;
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
visitorAuthToken: string | null;
},
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
await Promise.all([
dataFetcher.getPublishedContentSite({
organizationId: ids.organization,
siteId: ids.site,
siteShareKey: ids.shareKey,
}),
fetchSpaceContextByIds(baseContext, ids),
]);
// override the title with the customization title
// TODO: remove this hack once we have a proper way to handle site customizations
const site = {
...orgSite,
...(customizations.site?.title ? { title: customizations.site.title } : {}),
};
const sections = ids.siteSection ? parseSiteSectionsList(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');
}
const siteSpaces =
siteStructure.type === 'siteSpaces'
? siteStructure.structure
: (sections?.current.siteSpaces ?? []);
const customization = (() => {
if (ids.siteSpace) {
const siteSpaceSettings = customizations.siteSpaces[ids.siteSpace];
if (siteSpaceSettings) {
return siteSpaceSettings;
}
// We got the pointer from an API and customizations from another.
// It's possible that the two are unsynced leading to not found customizations for the space.
// It's better to fallback on customization of the site that displaying an error.
console.warn('Customization not found for site space', ids.siteSpace);
}
return customizations.site;
})();
return {
...spaceContext,
organizationId: ids.organization,
site,
siteSpaces,
siteSpace,
customization,
structure: siteStructure,
sections,
scripts,
visitorAuthToken: ids.visitorAuthToken,
};
}
/**
* Fetch a space context by IDs.
*/
export async function fetchSpaceContextByIds(
baseContext: GitBookBaseContext,
ids: {
space: string;
shareKey: string | undefined;
changeRequest: string | undefined;
revision: string | undefined;
},
): Promise<GitBookSpaceContext> {
const { dataFetcher } = baseContext;
const [space, changeRequest] = await Promise.all([
dataFetcher.getSpace({
spaceId: ids.space,
shareKey: ids.shareKey,
}),
ids.changeRequest
? dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
})
: null,
]);
const revisionId = changeRequest?.revision ?? ids.revision ?? space.revision;
const pages = await dataFetcher.getRevisionPages({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
});
return {
...baseContext,
organizationId: space.organization,
space,
pages,
changeRequest,
revisionId,
shareKey: ids.shareKey,
};
}
function parseSiteSectionsList(structure: SiteStructure, siteSectionId: string) {
const sections = getSiteStructureSections(structure);
const section = sections.find((section) => section.id === siteSectionId);
assert(section, 'A section must be defined when there are multiple sections');
return { list: sections, current: section } satisfies SiteSections;
}
-465
View File
@@ -1,465 +0,0 @@
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { ComputedContentSource, GitBookAPI } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { GitBookDataFetcher } from './types';
import {
getChangeRequestCacheTag,
getHostnameCacheTag,
getOpenAPISpecCacheTag,
getSiteCacheTag,
getSpaceCacheTag,
} from '../cache';
interface DataFetcherInput {
/**
* API host to use.
*/
apiEndpoint: string;
/**
* API token.
*/
apiToken: string | null;
}
const commonInput: DataFetcherInput = {
apiEndpoint: GITBOOK_API_URL,
apiToken: GITBOOK_API_TOKEN,
};
/**
* Create a data fetcher using an API token.
* The data are being cached by Next.js built-in cache.
*/
export function createDataFetcher(input: DataFetcherInput = commonInput): GitBookDataFetcher {
return {
apiEndpoint: input.apiEndpoint,
//
// API that are tied to the token
//
getPublishedContentSite(params) {
return getPublishedContentSite(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
});
},
getSiteRedirectBySource(params) {
return getSiteRedirectBySource(input, {
organizationId: params.organizationId,
siteId: params.siteId,
siteShareKey: params.siteShareKey,
source: params.source,
});
},
getRevision(params) {
return getRevision(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
});
},
getRevisionPages(params) {
return getRevisionPages(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
metadata: params.metadata,
});
},
getRevisionFile(params) {
return getRevisionFile(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
fileId: params.fileId,
});
},
getRevisionPageByPath(params) {
return getRevisionPageByPath(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
path: params.path,
});
},
getReusableContent(params) {
return getReusableContent(input, {
spaceId: params.spaceId,
revisionId: params.revisionId,
reusableContentId: params.reusableContentId,
});
},
getLatestOpenAPISpecVersionContent(params) {
return getLatestOpenAPISpecVersionContent(input, {
organizationId: params.organizationId,
slug: params.slug,
});
},
getSpace(params) {
return getSpace(input, {
spaceId: params.spaceId,
shareKey: params.shareKey,
});
},
getChangeRequest(params) {
return getChangeRequest(input, {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
});
},
getDocument(params) {
return getDocument(input, {
spaceId: params.spaceId,
documentId: params.documentId,
});
},
getComputedDocument(params) {
return getComputedDocument(input, {
spaceId: params.spaceId,
source: params.source,
});
},
//
// API that are not tied to the token
// where the data is the same for all users
//
getUserById(userId) {
return getUserById(commonInput, userId);
},
getPublishedContentByUrl(params) {
return getPublishedContentByUrl(commonInput, {
url: params.url,
visitorAuthToken: params.visitorAuthToken,
redirectOnError: params.redirectOnError,
});
},
};
}
async function getUserById(input: DataFetcherInput, userId: string) {
'use cache';
try {
const res = await getAPI(input).users.getUserById(userId);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getSpace(
input: DataFetcherInput,
params: {
spaceId: string;
shareKey: string | undefined;
},
) {
'use cache';
cacheTag(getSpaceCacheTag(params.spaceId));
const res = await getAPI(input).spaces.getSpaceById(params.spaceId, {
shareKey: params.shareKey,
});
return res.data;
}
async function getChangeRequest(
input: DataFetcherInput,
params: {
spaceId: string;
changeRequestId: string;
},
) {
'use cache';
try {
const res = await getAPI(input).spaces.getChangeRequestById(
params.spaceId,
params.changeRequestId,
);
cacheTag(getChangeRequestCacheTag(params.spaceId, res.data.id));
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getRevision(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
},
) {
'use cache';
const res = await getAPI(input).spaces.getRevisionById(params.spaceId, params.revisionId, {
metadata: params.metadata,
});
return res.data;
}
async function getRevisionPages(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
metadata: boolean;
},
) {
'use cache';
const res = await getAPI(input).spaces.listPagesInRevisionById(
params.spaceId,
params.revisionId,
{
metadata: params.metadata,
},
);
return res.data.pages;
}
async function getRevisionFile(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
fileId: string;
},
) {
'use cache';
try {
const res = await getAPI(input).spaces.getFileInRevisionById(
params.spaceId,
params.revisionId,
params.fileId,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getRevisionPageByPath(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
path: string;
},
) {
'use cache';
const encodedPath = encodeURIComponent(params.path);
try {
const res = await getAPI(input).spaces.getPageInRevisionByPath(
params.spaceId,
params.revisionId,
encodedPath,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getDocument(
input: DataFetcherInput,
params: {
spaceId: string;
documentId: string;
},
) {
'use cache';
const res = await getAPI(input).spaces.getDocumentById(params.spaceId, params.documentId);
return res.data;
}
async function getComputedDocument(
input: DataFetcherInput,
params: {
spaceId: string;
source: ComputedContentSource;
},
) {
'use cache';
const res = await getAPI(input).spaces.getComputedDocument(params.spaceId, {
source: params.source,
});
return res.data;
}
async function getReusableContent(
input: DataFetcherInput,
params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
},
) {
'use cache';
try {
const res = await getAPI(input).spaces.getReusableContentInRevisionById(
params.spaceId,
params.revisionId,
params.reusableContentId,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getLatestOpenAPISpecVersionContent(
input: DataFetcherInput,
params: {
organizationId: string;
slug: string;
},
) {
'use cache';
cacheTag(getOpenAPISpecCacheTag(params.organizationId, params.slug));
try {
const res = await getAPI(input).orgs.getLatestOpenApiSpecVersionContent(
params.organizationId,
params.slug,
);
return res.data;
} catch (error) {
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
async function getPublishedContentByUrl(
input: DataFetcherInput,
params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
},
) {
'use cache';
const { url, visitorAuthToken, redirectOnError } = params;
const hostname = new URL(url).hostname;
cacheTag(getHostnameCacheTag(hostname));
const res = await getAPI(input).urls.getPublishedContentByUrl({
url,
visitorAuthToken: visitorAuthToken ?? undefined,
redirectOnError,
});
if ('site' in res.data) {
cacheTag(getSiteCacheTag(res.data.site));
}
return res.data;
}
async function getPublishedContentSite(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
},
) {
'use cache';
cacheTag(getSiteCacheTag(params.siteId));
const res = await getAPI(input).orgs.getPublishedContentSite(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
},
);
return res.data;
}
async function getSiteRedirectBySource(
input: DataFetcherInput,
params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
},
) {
'use cache';
try {
const res = await getAPI(input).orgs.getSiteRedirectBySource(
params.organizationId,
params.siteId,
{
shareKey: params.siteShareKey,
source: params.source,
},
);
return res.data;
} catch (error) {
// 422 is returned when the source is invalid
// we don't want to throw but just return null
if (checkHasErrorCode(error, 422)) {
return null;
}
if (checkHasErrorCode(error, 404)) {
return null;
}
throw error;
}
}
function getAPI(input: DataFetcherInput) {
const { apiEndpoint, apiToken } = input;
const api = new GitBookAPI({
authToken: apiToken ?? undefined,
endpoint: apiEndpoint,
userAgent: GITBOOK_USER_AGENT,
});
return api;
}
function checkHasErrorCode(error: unknown, code: number) {
return error instanceof Error && 'code' in error && error.code === code;
}
@@ -1,4 +0,0 @@
export * from './api';
export * from './types';
export * from './utils';
export * from './urls';
-124
View File
@@ -1,124 +0,0 @@
import type * as api from '@gitbook/api';
/**
* Generic fetcher for GitBook data.
* It is used between v1 and v2.
*/
export interface GitBookDataFetcher {
/**
* Endpoint of the API.
*/
apiEndpoint: string;
/**
* Get a user by its ID.
*/
getUserById(userId: string): Promise<api.User | null>;
/**
* Get a published content by its URL.
*/
getPublishedContentByUrl(params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<api.PublishedSiteContentLookup>;
/**
* Get a published content site by its organization ID and site ID.
*/
getPublishedContentSite(params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
}): Promise<api.PublishedContentSite>;
/**
* Get a space by its ID.
*/
getSpace(params: { spaceId: string; shareKey: string | undefined }): Promise<api.Space>;
/**
* Get a change request by its space ID and change request ID.
*/
getChangeRequest(params: {
spaceId: string;
changeRequestId: string;
}): Promise<api.ChangeRequest | null>;
/**
* Get the revision by its space ID and revision ID.
*/
getRevision(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<api.Revision>;
/**
* Get the revision pages by its space ID and revision ID.
*/
getRevisionPages(params: {
spaceId: string;
revisionId: string;
metadata: boolean;
}): Promise<api.RevisionPage[]>;
/**
* Get a revision file by its space ID, revision ID and file ID.
*/
getRevisionFile(params: {
spaceId: string;
revisionId: string;
fileId: string;
}): Promise<api.RevisionFile | null>;
/**
* Get a revision page by its path.
*/
getRevisionPageByPath(params: {
spaceId: string;
revisionId: string;
path: string;
}): Promise<api.RevisionPageDocument | api.RevisionPageGroup | null>;
/**
* Get a document by its space ID and document ID.
*/
getDocument(params: { spaceId: string; documentId: string }): Promise<api.JSONDocument>;
/**
* Get a computed document by its space ID and computed source.
*/
getComputedDocument(params: {
spaceId: string;
source: api.ComputedContentSource;
}): Promise<api.JSONDocument>;
/**
* Get a reusable content by its space ID, revision ID and reusable content ID.
*/
getReusableContent(params: {
spaceId: string;
revisionId: string;
reusableContentId: string;
}): Promise<api.RevisionReusableContent | null>;
/**
* Get the latest OpenAPI spec version content by its organization ID and slug.
*/
getLatestOpenAPISpecVersionContent(params: {
organizationId: string;
slug: string;
}): Promise<api.OpenAPISpecContent | null>;
/**
* Get a site redirect by its source path.
*/
getSiteRedirectBySource(params: {
organizationId: string;
siteId: string;
siteShareKey: string | undefined;
source: string;
}): Promise<{ redirect: api.SiteRedirect | null; target: string } | null>;
}
-252
View File
@@ -1,252 +0,0 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath } from '@/lib/paths';
import { GitBookAPI, type GitBookAPIError, type PublishedSiteContentLookup } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
/**
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<
| { data: PublishedSiteContentLookup; error?: undefined }
| { data?: undefined; error: Error | GitBookAPIError }
> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
console.log(
`lookup content for url "${url.toString()}", with ${lookup.urls.length} alternatives`,
);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = new GitBookAPI({
authToken: GITBOOK_API_TOKEN ?? undefined,
endpoint: GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
});
const callResult = await tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
cache: true,
},
{
signal,
headers: {
'x-gitbook-force-cache': 'true',
},
},
),
);
if (callResult.error) {
if (alternative.primary) {
// We only return an error for the primary alternative (full URL),
// as other parts could result in errors due to the URL being incomplete (share links, etc).
return { error: callResult.error };
}
return null;
}
const {
data: { data },
} = callResult;
if ('redirect' in data) {
if (alternative.primary) {
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {
if (data.target === 'content') {
const redirect = new URL(data.redirect);
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
data.redirect = redirect.toString();
} else {
const redirect = new URL(data.redirect);
if (redirect.searchParams.has('location')) {
redirect.searchParams.set(
'location',
joinPath(
redirect.searchParams.get('location') ?? '',
alternative.extraPath,
),
);
data.redirect = redirect.toString();
}
}
}
return { data };
}
return null;
}
/**
* We use the following criteria to determine if the lookup result is the right one:
* - the primary alternative was resolved (because that's the longest or most inclusive path)
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
*
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
*/
if (alternative.primary || ('site' in data && data.complete)) {
const siteResult: PublishedSiteContentLookup = {
...data,
changeRequest: data.changeRequest ?? lookup.changeRequest,
revision: data.revision ?? lookup.revision,
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
};
return { data: siteResult };
}
return null;
});
if (!result) {
return {
error: new Error('No content found'),
};
}
return result;
}
/**
* For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content.
* The approach is optimized to aim at reusing cached lookup results as much as possible.
*
* The cases can be:
*
* Custom hostname
* - Public content has a custom hostname: docs.company.com/<path>
* - Public content has a custom hostname with a share-link: docs.company.com/<link>/<path>
* - Public content has a custom hostname with a variant: docs.company.com/v/<variant>/<path>
* - Public content has a custom hostname with a variant and a share-link: docs.company.com/<link>/v/<variant>/<path>
* - Revisions in a custom hostname: docs.company.com/~/revisions/<id>/<path>
* - Changes in a custom hostname: docs.company.com/~/changes/<id>/<path>
*
* Custom hostname in the organization or GitBook domain (company.gitbook.io)
* - Public content has a custom hostname in the organization: docs.company.com/<space>/<path>
* - Public content has a custom hostname in the organization with a share-link: docs.company.com/<space>/<link>/<path>
* - Public content has a custom hostname in the organization with a variant: docs.company.com/<space>/v/<variant>/<path>
* - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com/<space>/<link>/v/<variant>/<path>
*/
export function getURLLookupAlternatives(input: URL) {
const url = normalizeURL(input);
let basePath: string | undefined = undefined;
let changeRequest: string | undefined = undefined;
let revision: string | undefined = undefined;
const alternatives: Array<{ url: string; extraPath: string; primary: boolean }> = [];
const pushAlternative = (adding: URL, extraPath: string) => {
const existing = alternatives.find((alt) => alt.url === adding.toString());
if (existing) {
if (existing.extraPath !== extraPath) {
throw new Error(
`Invalid extraPath ${extraPath} for url ${adding.toString()}, already set to ${
existing.extraPath
}`,
);
}
return;
}
alternatives.push({
url: adding.toString(),
extraPath,
primary: adding.toString() === url.toString(),
});
};
const pathSegments = url.pathname.slice(1).split('/');
const tildeIndex = pathSegments.indexOf('~');
// URL looks like a specific content url (with ~/revisions/ or ~/changes/ in the path)
// We only start matching after the ~/revisions/ or ~/changes/ segment and we ignore everything before it
if (
tildeIndex >= 0 &&
(pathSegments[tildeIndex + 1] === 'revisions' || pathSegments[tildeIndex + 1] === 'changes')
) {
const tildeIndex = pathSegments.indexOf('~');
const revisionOrChangeIdIndex = tildeIndex + 2;
basePath = pathSegments.slice(tildeIndex, revisionOrChangeIdIndex + 1).join('/');
if (pathSegments[tildeIndex + 1] === 'revisions') {
revision = pathSegments[revisionOrChangeIdIndex];
} else {
changeRequest = pathSegments[revisionOrChangeIdIndex];
}
// Match up to the tilde
const contentURL = new URL(url);
contentURL.pathname = pathSegments.slice(0, tildeIndex).join('/');
pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/'));
}
// URL looks like a collection url (with /v/ in the path)
// We only start matching after the /v/ segment and we ignore everything before it
// to avoid potentially matching as a page not found under the default space in the collection
else if (pathSegments.includes('v')) {
const collectionURL = new URL(url);
const vIndex = pathSegments.indexOf('v');
collectionURL.pathname = pathSegments.slice(0, vIndex + 2).join('/');
pushAlternative(collectionURL, pathSegments.slice(vIndex + 2).join('/'));
} else {
// Match only with the host, if it can be a custom hostname
// It should cover most cases of custom domains, and with caching, it should be fast.
if (!url.hostname.includes('.gitbook.io') || pathSegments.length === 0) {
const noPathURL = new URL(url);
noPathURL.pathname = '/';
pushAlternative(noPathURL, url.pathname.slice(1));
}
// Otherwise match with the first four segments of the path
for (let i = 1; i <= 4; i++) {
if (pathSegments.length >= i) {
const shortURL = new URL(url);
shortURL.pathname = pathSegments.slice(0, i).join('/');
pushAlternative(shortURL, pathSegments.slice(i).join('/'));
}
}
}
// Mark the longuest entry to lookup as primary
alternatives.sort((a, b) => b.extraPath.length - a.extraPath.length);
if (alternatives.length > 0) {
alternatives[alternatives.length - 1].primary = true;
}
return { urls: alternatives, basePath, changeRequest, revision };
}
/**
* Normalize a URL to remove duplicate slashes and trailing slashes
* and transform the pathname to lowercase.
*/
export function normalizeURL(url: URL) {
const result = new URL(url);
result.pathname = url.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
return result;
}
/**
* Strip the search params from a URL
*/
export function stripURLSearch(url: URL): URL {
const stripped = new URL(url.toString());
stripped.search = '';
return stripped;
}
-19
View File
@@ -1,19 +0,0 @@
import { RevisionPageDocument } from '@gitbook/api';
import { GitBookDataFetcher } from './types';
/**
* Get the document for a page.
*/
export async function getPageDocument(
dataFetcher: GitBookDataFetcher,
spaceId: string,
page: RevisionPageDocument,
) {
if (page.documentId) {
return dataFetcher.getDocument({ spaceId, documentId: page.documentId });
} else if (page.computed) {
return dataFetcher.getComputedDocument({ spaceId, source: page.computed });
}
return null;
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Main host on which GitBook is running.
*/
export const GITBOOK_URL =
process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: ((process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined) ??
process.env.GITBOOK_URL ??
'');
/**
* GitBook app URL.
*/
export const GITBOOK_APP_URL = process.env.NEXT_PUBLIC_GITBOOK_APP_URL ?? `https://app.gitbook.com`;
/**
* Default GitBook API URL endpoint.
*/
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
/**
* Default GitBook API token.
* It can be use to avoid rate-limiting.
*/
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
/**
* User agent to use for API requests.
*/
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
/**
* Whether to disable tracking of events into site insights.
* This is used to disable tracking in development mode.
*/
export const GITBOOK_DISABLE_TRACKING = Boolean(
!!process.env.GITBOOK_DISABLE_TRACKING || process.env.NODE_ENV !== 'production',
);
@@ -1,4 +0,0 @@
export * from './types';
export * from './createImageResizer';
export * from './signatures';
export * from './utils';
@@ -1,107 +0,0 @@
import 'server-only';
import fnv1a from '@sindresorhus/fnv1a';
import type { MaybePromise } from 'p-map';
/**
* GitBook has supported different version of image signing in the past. To maintain backwards
* compatibility, we retain the ability to verify older signatures.
*/
export type SignatureVersion = '0' | '1' | '2';
/**
* The current version of the signature.
*/
export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2';
type SignFnInput = {
url: string;
host: string;
};
type SignFn = (input: SignFnInput) => MaybePromise<string>;
/**
* Verify a signature of an image URL
*/
export async function verifyImageSignature(
input: SignFnInput,
{ signature, version }: { signature: string; version: SignatureVersion },
): Promise<boolean> {
const generator = IMAGE_SIGNATURE_FUNCTIONS[version];
const generated = await generator(input);
return generated === signature;
}
/**
* Generate an image signature. Also returns the version of the image signing algorithm that was used.
*
* This function is sync. If you need to implement an async version of image signing, you'll need to change
* ths signature of this fn and where it's used.
*/
export async function generateImageSignature(input: SignFnInput): Promise<{
signature: string;
version: SignatureVersion;
}> {
const result = await generateSignatureV2(input);
return { signature: result, version: CURRENT_SIGNATURE_VERSION };
}
// Reused buffer for FNV-1a hashing in the v2 algorithm
const fnv1aUtf8Buffer = new Uint8Array(512);
/**
* Generate a signature for an image.
* The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain.
*/
const generateSignatureV2: SignFn = async (input) => {
const all = [
input.url,
input.host, // The hostname is used to avoid serving images from other sites on the same domain
process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
]
.filter(Boolean)
.join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8Buffer }).toString(16);
};
// Reused buffer for FNV-1a hashing in the v1 algorithm
const fnv1aUtf8BufferV1 = new Uint8Array(512);
/**
* New and faster algorithm to generate a signature for an image.
* When setting it in a URL, we use version '1' for the 'sv' querystring parameneter
* to know that it was the algorithm that was used.
*/
const generateSignatureV1: SignFn = async (input) => {
const all = [input.url, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16);
};
/**
* Initial algorithm used to generate a signature for an image. It didn't use any versioning in the URL.
* We still need it to validate older signatures that were generated without versioning
* but still exist in previously generated and cached content.
*/
const generateSignatureV0: SignFn = async (input) => {
const all = [input.url, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':');
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all));
// Convert ArrayBuffer to hex string
const hashArray = Array.from(new Uint8Array(hash));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
};
/**
* A mapping of signature versions to signature functions.
*/
const IMAGE_SIGNATURE_FUNCTIONS: Record<SignatureVersion, SignFn> = {
'0': generateSignatureV0,
'1': generateSignatureV1,
'2': generateSignatureV2,
};
export function isSignatureVersion(input: string): input is SignatureVersion {
return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input);
}
@@ -1,29 +0,0 @@
export type GetImageSizeOptions = {
dpr?: number;
};
export type ResizeImageOptions = GetImageSizeOptions & {
width?: number;
height?: number;
dpr?: number;
quality?: number;
};
interface ImageSize {
width: number;
height: number;
}
export type ImageResizer = {
/**
* Resize an image.
* @param input - The image URL to resize.
* @param options - The options to resize the image.
*/
resize(input: string): null | ((options: ResizeImageOptions) => Promise<string>);
/**
* Get the size of an image.
*/
getImageSize(input: string, options: GetImageSizeOptions): Promise<ImageSize | null>;
};
@@ -1,17 +0,0 @@
import { ImageResizer, ResizeImageOptions } from './types';
/**
* Quick utility to get a resized image URL.
*/
export async function getResizedImageURL(
resizer: ImageResizer,
url: string,
options: ResizeImageOptions,
) {
const getURL = resizer.resize(url);
if (!getURL) {
return url;
}
return await getURL(options);
}
-91
View File
@@ -1,91 +0,0 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import { getPagePath } from '@/lib/pages';
/**
* Generic interface to generate links based on a given context.
*/
export interface GitBookSpaceLinker {
/**
* Generate an absolute path for a relative path in the current space content.
*/
toPathInSpace(relativePath: string): string;
/**
* Generate an absolute path for a page in the current space.
*/
toPathForPage(input: {
pages: RevisionPage[];
page: RevisionPageDocument | RevisionPageGroup;
anchor?: string;
}): string;
/**
* Generate an absolute URL for a given path.
*/
toAbsoluteURL(absolutePath: string): string;
}
/**
* Create a linker to resolve links in a context being served on a specific URL.
*/
export function createLinker(
/** Where the top of the space is served on */
servedOn: {
protocol?: string;
host: string;
pathname: string;
},
): GitBookSpaceLinker {
if (servedOn.host.includes('/')) {
throw new Error('Host cannot include a slash');
}
const linker: GitBookSpaceLinker = {
toPathInSpace(relativePath: string): string {
return joinPaths(servedOn.pathname, relativePath);
},
toAbsoluteURL(absolutePath: string): string {
return `${servedOn.protocol ?? 'https:'}//${joinPaths(servedOn.host, absolutePath)}`;
},
toPathForPage({ pages, page, anchor }) {
return linker.toPathInSpace(getPagePath(pages, page)) + (anchor ? '#' + anchor : '');
},
};
return linker;
}
/**
* Append a prefix to a linker.
*/
export function appendPrefixToLinker(
linker: GitBookSpaceLinker,
prefix: string,
): GitBookSpaceLinker {
const linkerWithPrefix: GitBookSpaceLinker = {
toPathInSpace(relativePath: string): string {
return linker.toPathInSpace(joinPaths(prefix, relativePath));
},
toAbsoluteURL(absolutePath: string): string {
return linker.toAbsoluteURL(joinPaths(prefix, absolutePath));
},
toPathForPage({ pages, page, anchor }) {
return (
linkerWithPrefix.toPathInSpace(getPagePath(pages, page)) +
(anchor ? '#' + anchor : '')
);
},
};
return linkerWithPrefix;
}
function joinPaths(prefix: string, path: string): string {
const prefixPath = prefix.endsWith('/') ? prefix : prefix + '/';
const suffixPath = path.startsWith('/') ? path.slice(1) : path;
return prefixPath + suffixPath;
}
-36
View File
@@ -1,36 +0,0 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { headers } from 'next/headers';
export enum MiddlewareHeaders {
URLMode = 'x-gitbook-url-mode',
Theme = 'x-gitbook-theme',
Customization = 'x-gitbook-customization',
}
/**
* Get the URL mode from the middleware headers.
*/
export async function getURLModeFromMiddleware(): Promise<'url' | 'url-host'> {
const headersList = await headers();
const mode = headersList.get(MiddlewareHeaders.URLMode);
if (!mode) {
throw new Error('URL mode is not set by the middleware');
}
return mode as 'url' | 'url-host';
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
export async function getThemeFromMiddleware() {
const headersList = await headers();
const queryStringTheme = headersList.get(MiddlewareHeaders.Theme);
if (!queryStringTheme) {
return null;
}
return queryStringTheme === 'light'
? CustomizationThemeMode.Light
: CustomizationThemeMode.Dark;
}
-111
View File
@@ -1,111 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { GitBookAPIError } from '@gitbook/api';
import { removeTrailingSlash } from '@/lib/paths';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import { getPublishedContentByURL } from '@v2/lib/data';
export const config = {
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
};
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
export async function middleware(request: NextRequest) {
const extracted = extractURL(request);
if (extracted) {
return serveSiteByURL(request, extracted);
}
return NextResponse.next();
}
/**
* Serve site by URL.
*/
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
const dynamicHeaders = getDynamicHeaders(request);
const { url, mode } = urlWithMode;
const result = await getPublishedContentByURL({
url: url.toString(),
visitorAuthToken: null,
redirectOnError: false,
});
if (result.error) {
if (result.error instanceof GitBookAPIError) {
return NextResponse.json(
{
error: result.error.message,
},
{ status: result.error.code },
);
} else {
throw result.error;
}
}
const { data } = result;
if ('redirect' in data) {
return NextResponse.redirect(data.redirect);
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
if (dynamicHeaders) {
for (const [key, value] of Object.entries(dynamicHeaders)) {
requestHeaders.set(key, value);
}
}
const route = [
'sites',
dynamicHeaders ? 'dynamic' : 'static',
mode,
encodeURIComponent(url.host + data.basePath),
encodeURIComponent(removeTrailingSlash(data.pathname) || '/'),
].join('/');
return NextResponse.rewrite(new URL('/' + route, request.url), {
headers: requestHeaders,
});
}
/**
* The URL of the GitBook content can be passed in 2 different ways:
* - Hostname is in the `X-GitBook-Host` header and the pathname is the path in the request URL.
* - The request URL is matching `/url/:url`
*/
function extractURL(request: NextRequest): URLWithMode | null {
const xGitbookHost = request.headers.get('x-gitbook-host');
if (xGitbookHost) {
return {
url: new URL(`https://${xGitbookHost}${request.nextUrl.pathname}`),
mode: 'url-host',
};
}
const prefix = '/url/';
if (request.nextUrl.pathname.startsWith(prefix)) {
return {
url: new URL(`https://${request.nextUrl.pathname.slice(prefix.length)}`),
mode: 'url',
};
}
return null;
}
/**
* Evaluate if a request is dynamic or static.
*/
function getDynamicHeaders(request: NextRequest): null | Record<string, string> {
// TODO:
// - check token in query string
// - check token in cookies
// - check special headers or query string
return null;
}
-10
View File
@@ -1,10 +0,0 @@
import config from '../gitbook/tailwind.config';
export default {
...config,
content: [
'../gitbook/src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'../gitbook/src/components/**/*.{js,ts,jsx,tsx,mdx}',
'../gitbook/src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
};

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