Compare commits

..

3 Commits

Author SHA1 Message Date
Vib Bhardwaj 0fac399b3f Add lockfile 2024-05-14 17:18:41 +02:00
Vib Bhardwaj 1d8875416b Remove console log 2024-05-14 17:13:25 +02:00
Vib Bhardwaj 24a889cf50 Add support for examples in the OpenAPI block 2024-05-14 17:12:18 +02:00
811 changed files with 17336 additions and 104427 deletions
-8
View File
@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
@@ -23,5 +23,5 @@
## Caching
# GITBOOK_OUTPUT_CACHE=true
### Silent logs
# SILENT=true
### Sentry ###
# SENTRY_DSN=xxx
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "next/core-web-vitals",
"plugins": ["import"],
"rules": {
"import/order": [
"error",
{
"groups": ["builtin", "external", ["internal", "parent", "sibling", "index"]],
"newlines-between": "always",
"distinctGroup": true,
"pathGroups": [
{
"pattern": "@/**",
"group": "external",
"position": "after"
},
{
"pattern": "@gitbook/**",
"group": "external",
"position": "after"
}
],
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
],
"@next/next/no-img-element": ["off"]
}
}
+7 -21
View File
@@ -1,8 +1,6 @@
# Welcome to GitBook's contributing guide!
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team.In this guide, you'll learn the different ways you can contribute.
## Types of Contributions
@@ -47,25 +45,13 @@ Any contribution you make can be made to the code located in this repository. In
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
##### GitHub Codespaces:
- [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally.
#### 2. Create a working branch and start with your changes
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
#### 3. Install dependencies and run the project locally
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
You can easily do this by running the command `nvm use`.
To start your local version of GitBook, run the command `bun dev`.
#### 4. Preview your changes
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. After creating the branch, you can start making changes!
### Commit your update
@@ -85,4 +71,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
### Your PR is merged
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
Congratulations 🎉Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
+2 -4
View File
@@ -7,12 +7,12 @@ runs:
- name: 🏗 Prepare Playwright env
shell: bash
run: |
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version')
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
# 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'
@@ -24,7 +24,6 @@ runs:
- name: 🏗 Install Playwright browser binaries & OS dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
working-directory: packages/gitbook
run: |
bun x playwright install --with-deps chromium
@@ -32,6 +31,5 @@ runs:
- name: 🏗 Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
working-directory: packages/gitbook
run: |
bun x playwright install-deps
@@ -1,80 +0,0 @@
name: 'Deploy cloudflare'
description: 'Deploy GitBook to Cloudflare'
inputs:
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
apiToken:
description: 'Cloudflare API token'
required: true
accountId:
description: 'Cloudflare account ID'
required: true
environment:
description: 'Cloudflare environment to deploy to (staging, production, preview)'
required: true
deploy:
description: 'Deploy as main version for all traffic instead of uploading versions'
required: true
commitTag:
description: 'Commit branch to associate with the deployment'
required: true
commitMessage:
description: 'Commit message to associate with the deployment'
required: true
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
shell: bash
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Load secret
uses: 1password/load-secrets-action@v2
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ inputs.opItem }}/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build worker
run: bun run turbo build:v2:cloudflare
env:
GITBOOK_RUNTIME: cloudflare
shell: bash
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.14.0
with:
apiToken: ${{ inputs.apiToken }}
accountId: ${{ inputs.accountId }}
workingDirectory: ./
wranglerVersion: '3.112.0'
environment: ${{ inputs.environment }}
command: ${{ inputs.deploy == 'true' && 'deploy' || format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook-v2/wrangler.jsonc
- name: Outputs
shell: bash
env:
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
@@ -1,79 +0,0 @@
name: 'Deploy vercel'
description: 'Deploy GitBook to Vercel'
inputs:
vercelOrg:
description: 'Vercel organization'
required: true
vercelProject:
description: 'Vercel project'
required: true
vercelToken:
description: 'Vercel token'
required: true
opItem:
description: '1Password item to load secrets from'
required: true
opServiceAccount:
description: '1Password service account token'
required: true
environment:
description: 'Environment to deploy to'
required: true
outputs:
deployment-url:
description: "Deployment URL"
value: ${{ steps.deploy.outputs.deployment-url }}
runs:
using: 'composite'
steps:
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
shell: bash
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Pull Vercel Environment Information
run: bun run vercel pull --yes --environment=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
- name: Load secret
uses: 1password/load-secrets-action@v2
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.opServiceAccount }}
GITBOOK_URL: ${{ inputs.opItem }}/GITBOOK_URL
GITBOOK_ICONS_URL: ${{ inputs.opItem }}/GITBOOK_ICONS_URL
GITBOOK_ICONS_TOKEN: ${{ inputs.opItem }}/GITBOOK_ICONS_TOKEN
GITBOOK_SECRET: ${{ inputs.opItem }}/GITBOOK_SECRET
GITBOOK_APP_URL: ${{ inputs.opItem }}/GITBOOK_APP_URL
GITBOOK_API_URL: ${{ inputs.opItem }}/GITBOOK_API_URL
GITBOOK_API_PUBLIC_URL: ${{ inputs.opItem }}/GITBOOK_API_PUBLIC_URL
GITBOOK_API_TOKEN: ${{ inputs.opItem }}/GITBOOK_API_TOKEN
GITBOOK_INTEGRATIONS_HOST: ${{ inputs.opItem }}/GITBOOK_INTEGRATIONS_HOST
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_SIGNING_KEY
GITBOOK_IMAGE_RESIZE_URL: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_URL
GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX
GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL
- name: Build Project Artifacts
run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }}
shell: bash
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
GITBOOK_RUNTIME: vercel
- name: Deploy Project Artifacts to Vercel
id: deploy
shell: bash
run: |
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }})
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
env:
VERCEL_ORG_ID: ${{ inputs.vercelOrg }}
VERCEL_PROJECT_ID: ${{ inputs.vercelProject }}
- name: Outputs
shell: bash
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
-9
View File
@@ -1,9 +0,0 @@
name: 'Setup Bun'
description: 'Install Bun and cache dependencies'
runs:
using: 'composite'
steps:
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: 'package.json'
+139 -33
View File
@@ -4,63 +4,169 @@ on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
format:
deploy:
name: Deploy to Cloudflare Pages
runs-on: ubuntu-latest
name: Format
timeout-minutes: 6
permissions:
contents: read
deployments: write
outputs:
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.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format:check
test:
- 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
echo "GITBOOK_ASSETS_PREFIX=https://static.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 Next.js with next-on-pages
run: bun run build:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
- 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: ./.vercel/output/static
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
workingDirectory: ./
- name: Outputs
run: |
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/
visual-testing:
runs-on: ubuntu-latest
name: Test
timeout-minutes: 6
name: Visual Testing
needs: deploy
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.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run pagespeed tests
run: bun ./tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
name: Format
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun format --check .
lint:
runs-on: ubuntu-latest
name: Lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun lint --no-cache
test:
runs-on: ubuntu-latest
name: Test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun unit
build-oss:
# CI to check that the repository builds correctly on a machine without the credentials
runs-on: ubuntu-latest
name: Build (Open Source)
timeout-minutes: 6
env:
NPM_TOKEN_READONLY: ''
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build
typecheck:
runs-on: ubuntu-latest
name: Typecheck
timeout-minutes: 6
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.0.33
- name: Install dependencies
run: bun install --frozen-lockfile
env:
-300
View File
@@ -1,300 +0,0 @@
name: Preview
on:
pull_request:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v1-cloudflare:
name: Deploy v1 to Cloudflare Pages
runs-on: ubuntu-latest
environment:
name: ${{ github.ref == 'refs/heads/main' && '1c-production' || '1c-preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions:
contents: read
deployments: write
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 }}
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
if: github.ref == 'refs/heads/main'
run: |
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
- name: Build Next.js with next-on-pages
run: bun run turbo gitbook#build:cloudflare
env:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
GITBOOK_RUNTIME: cloudflare
- 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.112.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 }}"
deploy-v2-vercel:
name: Deploy v2 to Vercel (preview)
runs-on: ubuntu-latest
environment:
name: 2v-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to Vercel
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: preview
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (preview)
runs-on: ubuntu-latest
environment:
name: 2c-preview
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to Cloudflare
id: deploy
uses: ./.github/composite/deploy-cloudflare
with:
environment: preview
deploy: ${{ github.ref == 'refs/heads/main' }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-preview
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: ${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
commitMessage: ${{ github.sha }}
- name: Extract Worker ID
id: extract-worker-id
if: ${{ !steps.deploy.outputs.deployment-url }}
run: |
if [[ "${{ steps.deploy.outputs.command-output }}" =~ Worker\ Version\ ID:\ ([0-9a-f]{8})-([0-9a-f-]+) ]]; then
WORKER_ID_FIRST_PART="${BASH_REMATCH[1]}"
echo "worker-url=https://${WORKER_ID_FIRST_PART}-gitbook-open-v2-preview.gitbook.workers.dev/" >> $GITHUB_OUTPUT
fi
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url || steps.extract-worker-id.outputs.worker-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments (preview)
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy-v1-cloudflare
- 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
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}](${{ needs.deploy-v1-cloudflare.outputs.deployment-alias-url }}) | ${{ needs.deploy-v1-cloudflare.result == 'success' && '✅' || '❌' }} |
### Version 2
| 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` | `2v` | `2c` |
| --- | --- | --- | --- |
| GitBook | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/docs.gitbook.com) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/gitbook.gitbook.io/test-gitbook-open) |
edit-mode: replace
visual-testing-v1:
runs-on: ubuntu-latest
name: Visual Testing v1
needs: deploy-v1-cloudflare
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
visual-testing-v2-vercel:
runs-on: ubuntu-latest
name: Visual Testing v2
needs: deploy-v2-vercel
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-vercel'
visual-testing-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 10
if: startsWith(github.ref_name, 'cloudflare/')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'v2-cloudflare'
visual-testing-customers-v1:
runs-on: ubuntu-latest
name: Visual Testing Customers v1
needs: deploy-v1-cloudflare
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v1-cloudflare.outputs.deployment-url }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v1'
visual-testing-customers-v2:
runs-on: ubuntu-latest
name: Visual Testing Customers v2
needs: deploy-v2-vercel
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
visual-testing-customers-v2-cloudflare:
runs-on: ubuntu-latest
name: Visual Testing Customers v2 (Cloudflare)
needs: deploy-v2-cloudflare
timeout-minutes: 8
if: startsWith(github.ref_name, 'cloudflare/')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers-v2'
pagespeed-testing-v1:
runs-on: ubuntu-latest
name: PageSpeed Testing v1
needs: deploy-v1-cloudflare
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: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts
env:
BASE_URL: ${{needs.deploy-v1-cloudflare.outputs.deployment-url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
-55
View File
@@ -1,55 +0,0 @@
name: Production
on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (production)
runs-on: ubuntu-latest
environment:
name: 2v-production
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: production
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (production)
runs-on: ubuntu-latest
environment:
name: 2c-production
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-cloudflare
with:
environment: production
deploy: true
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-production
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
-55
View File
@@ -1,55 +0,0 @@
name: Staging
on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy-v2-vercel:
name: Deploy v2 to Vercel (staging)
runs-on: ubuntu-latest
environment:
name: 2v-staging
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-vercel
with:
environment: staging
vercelOrg: ${{ secrets.VERCEL_ORG_ID }}
vercelProject: ${{ secrets.VERCEL_PROJECT_ID }}
vercelToken: ${{ secrets.VERCEL_TOKEN }}
opItem: op://gitbook-open/2v-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker (staging)
runs-on: ubuntu-latest
environment:
name: 2c-staging
url: ${{ steps.deploy.outputs.deployment-url }}
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy
id: deploy
uses: ./.github/composite/deploy-cloudflare
with:
environment: staging
deploy: true
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
opItem: op://gitbook-open/2c-staging
opServiceAccount: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
commitTag: main
commitMessage: ${{ github.sha }}
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
-60
View File
@@ -1,60 +0,0 @@
name: Publish
on:
push:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
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: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: npm run release
version: npm run changeset-version
env:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview:
# For now it releases the cache-do to both preview and production
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
name: Release Preview
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Release preview packages
run: bun run release:preview
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+28 -7
View File
@@ -5,6 +5,16 @@ node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
@@ -14,14 +24,25 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Turbo
.turbo
# local env files
.env*.local
# Vercel
# vercel
.vercel
# Env files
.env.local
# TypeScript
# typescript
*.tsbuildinfo
next-env.d.ts
# visual tests
screenshots/
# Sentry Config File
.sentryclirc
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# Generated public files
/public/~gitbook/static/
-1
View File
@@ -1 +0,0 @@
v20.6
+1
View File
@@ -0,0 +1 @@
.next
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"tabWidth": 4
}
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["biomejs.biome"]
}
+1 -10
View File
@@ -7,14 +7,5 @@
["style \\=([^;]*);", "\"([^\"]*)\""],
["style \\=([^;]*);", "\\`([^\\`]*)\\`"]
],
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"],
"prettier.enable": false,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
"tailwindCSS.classAttributes": ["class", "className", "style", ".*Style"]
}
+11 -68
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>
@@ -14,7 +14,7 @@
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
<p align="center">This repository contains the open source code used to render GitBook's published content.</p>
<p align="center">This repository contains the open-source code used to render GitBook's published content.</p>
<p align="center">
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
@@ -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
@@ -48,28 +46,19 @@ To run a local version of this project, please follow these simple steps.
git clone https://github.com/gitbookIO/gitbook.git
```
2. Ensure you are using the project's version of `node`. Running `nvm use` will change your local version to the correct one.
3. Install the project's dependencies through Bun.
2. Install the project's dependencies through Bun.
```
bun install
```
4. Start your local development server.
3. Start your local development server.
```
bun dev
```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
- http://localhost:3000/docs.gitbook.com
- http://localhost:3000/open-source.gitbook.io/midjourney
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
Then open the space in your web browser, using http://localhost:3000/<host>/<path> (example: http://localhost:3000/docs.gitbook.com).
### Other development commands
@@ -80,29 +69,9 @@ 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.
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/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
### Types of contributions
@@ -110,7 +79,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
#### Translations
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
The GitBook UI is rendered using a set of translation files found in [`src/intl/translations`](/src/intl/translations/). We welcome all additional translations for the UI.
#### Bugs
@@ -121,7 +90,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
> [!WARNING]
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
>
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
>
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
>
@@ -137,30 +106,10 @@ On the con side, you become responsible for the reliability of your published si
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
If you plan to distribute the code, you must make the source code public to comply with the GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
If you plan to distribute the code, you must the source code public to comply with GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
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:
@@ -170,12 +119,6 @@ GitBook wouldn't be possible without these projects:
- [Tailwind CSS](https://tailwindcss.com/)
- [Framer Motion](https://www.npmjs.com/package/framer-motion)
## Contributors
<a href="https://github.com/gitbookIO/gitbook/graphs/contributors">
<img src="https://contrib.rocks/image?repo=gitbookIO/gitbook" />
</a>
## Legacy GitBook (Deprecated)
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
-175
View File
@@ -1,175 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": [
"**/node_modules/**/*",
"**/dist/**/*",
"**/build/**/*",
"**/public/**/*",
"**/.next/**/*",
"**/.open-next/**/*",
"**/.turbo/**/*",
"**/.vercel/**/*",
"**/.cache/**/*",
"**/.wrangler/**/*",
"packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json"
]
},
"formatter": {
"enabled": true,
"useEditorconfig": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"bracketSpacing": true
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"performance": {
"noDelete": "warn"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": {
"noForEach": "off",
"noUselessFragments": "warn",
"noBannedTypes": "warn"
},
"correctness": {
"noUndeclaredVariables": "error",
"noUnusedVariables": "error",
"useArrayLiterals": "error",
"useHookAtTopLevel": "error",
"noUnusedImports": "error",
"noVoidElementsWithChildren": "warn",
"useJsxKeyInIterable": "warn",
"useExhaustiveDependencies": "warn",
"noUnknownFunction": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"noParameterAssign": "off",
"useThrowOnlyError": "error"
},
"suspicious": {
"noConsole": {
"level": "warn",
"options": {
"allow": ["assert", "error", "warn"]
}
},
"noExplicitAny": "warn",
"noImplicitAnyLet": "warn",
"noConfusingVoidType": "warn",
"noControlCharactersInRegex": "warn",
"noPrototypeBuiltins": "warn",
"noAssignInExpressions": "warn",
"noArrayIndexKey": "warn"
},
"a11y": {
"useSemanticElements": "warn",
"useKeyWithClickEvents": "warn",
"noSvgWithoutTitle": "warn",
"useButtonType": "warn",
"useIframeTitle": "warn",
"useAltText": "warn",
"noPositiveTabindex": "warn",
"useFocusableInteractive": "warn",
"useAriaPropsForRole": "warn",
"useValidAnchor": "warn",
"noLabelWithoutControl": "warn",
"noNoninteractiveTabindex": "warn"
},
"nursery": {
"useSortedClasses": {
"level": "error",
"fix": "safe",
"options": {
"attributes": ["class", "className", "style"],
"functions": ["clsx", "tw"]
}
}
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"overrides": [
{
"include": [
"packages/gitbook/**/*",
"packages/gitbook-v2/**/*",
"packages/react-openapi/**/*",
"packages/react-math/**/*",
"packages/react-contentkit/**/*",
"packages/icons/**/*"
],
"javascript": {
"globals": ["React"]
}
},
{
"include": ["packages/gitbook/**/*"],
"javascript": {
"globals": ["React", "GitBookIntegrationEvent"]
}
},
{
"include": ["*.css"],
"javascript": {
"globals": ["theme"]
}
},
{
"include": ["*.test.ts", "packages/gitbook/tests/**/*"],
"javascript": {
"globals": ["Bun"]
}
},
{
"include": [
"packages/cache-do/**/*",
"packages/gitbook/cf-env.d.ts",
"packages/gitbook/src/cloudflare-entrypoint.ts"
],
"javascript": {
"globals": [
"DurableObjectLocationHint",
"DurableObjectNamespace",
"DurableObjectStub",
"ContinentCode",
"Fetcher",
"ExportedHandler"
]
}
}
]
}
-5220
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
-2
View File
@@ -1,2 +0,0 @@
[install.scopes]
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+22
View File
@@ -0,0 +1,22 @@
# Caching
## Revalidating the cache
Invalidate cache can be done at two levels using tags:
- Data fetching cache
- Rendering cache
To invalidate and refetch the data cache, you can execute a POST request to `/~/gitbook/revalidate`:
```bash
curl --location --request POST 'https://gitbook/mycompany.com/~gitbook/revalidate' \
--header 'Content-Type: application/json' \
--data-raw '{"tags": ["space.id"]}'
```
To invalidate the rendering cache, the implementation mainly depends on the infrastructure serving the content, GitBook outputs a `Cache-Tag` header on every requests. The value of the header is a comma separated list of tags.
## Purging the cache
Purging the cache, without revalidating, is done by passing `"purge": true` in the request body.
+576
View File
@@ -0,0 +1,576 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationHeaderPreset,
CustomizationLocale,
CustomizationSettings,
} from '@gitbook/api';
import { test, expect, Page } from '@playwright/test';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import { getContentTestURL } from '../tests/utils';
interface Test {
name: string;
url: string;
run?: (page: Page) => Promise<unknown>;
fullPage?: boolean;
screenshot?: false;
}
interface TestsCase {
name: string;
baseUrl: string;
tests: Array<Test>;
}
const allLocales: CustomizationLocale[] = [
CustomizationLocale.Fr,
CustomizationLocale.Es,
CustomizationLocale.Ja,
CustomizationLocale.Zh,
];
async function waitForCookiesDialog(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Cookies' });
const accept = dialog.getByRole('button', { name: 'Accept' });
const reject = dialog.getByRole('button', { name: 'Reject' });
await expect(accept).toBeVisible();
await expect(reject).toBeVisible();
}
const testCases: TestsCase[] = [
{
name: 'GitBook Site (Single Variant)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
tests: [
{
name: 'Home',
url: '',
run: waitForCookiesDialog,
},
{
name: 'No variants dropdown',
url: '',
run: async (page) => {
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
0,
);
},
},
{
name: 'Search',
url: '?q=',
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
{
name: 'Not found',
url: 'content-not-found',
run: waitForCookiesDialog,
},
],
},
{
name: 'GitBook Site (Multi Variants)',
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
tests: [
{
name: 'Variants dropdown',
url: '',
run: async (page) => {
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
await spaceDrowpdown.waitFor();
},
},
{
name: 'Default variant',
url: '',
},
{
name: 'RFC variant',
url: 'v/rfcs',
},
],
},
{
name: 'GitBook',
baseUrl: 'https://docs.gitbook.com',
tests: [
{
name: 'Home',
url: '',
run: waitForCookiesDialog,
},
{
name: 'Search',
url: '?q=',
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
{
name: 'Not found',
url: 'content-not-found',
run: waitForCookiesDialog,
},
],
},
{
name: 'Versioning',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: [
{
name: 'Revision',
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
run: waitForCookiesDialog,
},
],
},
{
name: 'PDF',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: [
{
name: 'PDF',
url: '~gitbook/pdf?limit=10',
},
],
},
{
name: 'Content tests',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: [
{
name: 'Text',
url: 'text-page',
run: waitForCookiesDialog,
},
{
name: 'Long text',
url: 'text-page/long-text',
run: waitForCookiesDialog,
},
{
name: 'Images',
url: 'blocks/block-images',
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Inline Images',
url: 'blocks/inline-images',
run: waitForCookiesDialog,
},
{
name: 'Tabs',
url: 'blocks/tabs',
run: waitForCookiesDialog,
},
{
name: 'Hints',
url: 'blocks/hints',
run: waitForCookiesDialog,
},
{
name: 'Integration Blocks',
url: 'blocks/integrations',
run: waitForCookiesDialog,
},
{
name: 'Tables',
url: 'blocks/tables',
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Expandables',
url: 'blocks/expandables',
run: waitForCookiesDialog,
},
{
name: 'API Blocks',
url: 'blocks/api-blocks',
run: waitForCookiesDialog,
},
{
name: 'Headings',
url: 'blocks/headings',
run: waitForCookiesDialog,
},
{
name: 'Marks',
url: 'blocks/marks',
run: waitForCookiesDialog,
},
{
name: 'Emojis',
url: 'blocks/emojis',
run: waitForCookiesDialog,
},
{
name: 'Links',
url: 'blocks/links',
run: waitForCookiesDialog,
},
{
name: 'Lists',
url: 'blocks/lists',
fullPage: true,
},
{
name: 'Code',
url: 'blocks/code',
fullPage: true,
},
{
name: 'Cards',
url: 'blocks/cards',
fullPage: true,
},
{
name: 'Math',
url: 'blocks/math',
},
{
name: 'Embeds',
url: 'blocks/embeds',
fullPage: true,
},
{
name: 'Annotations',
url: 'blocks/annotations',
run: async (page) => {
await page.waitForSelector('[data-testid="annotation-button"]');
await page.click('[data-testid="annotation-button"]');
},
},
],
},
{
name: 'Page options',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: [
{
name: 'With cover',
url: 'page-options/page-with-cover',
run: waitForCookiesDialog,
},
{
name: 'With hero cover',
url: 'page-options/page-with-hero-cover',
run: waitForCookiesDialog,
},
{
name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog,
},
],
},
{
name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: [
{
name: 'Without header',
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.None,
links: [],
},
}),
run: waitForCookiesDialog,
},
],
},
{
name: 'Share links',
baseUrl: 'https://gitbook.gitbook.io/test-share-links/',
tests: [
{
name: 'Valid link',
url: 'Fc6mMII9FKgnwm7qqynx/',
run: waitForCookiesDialog,
},
{
name: 'Invalid link',
url: 'invalid/',
run: async (page) => {
await expect(
page.getByText('Authentication missing to access this content'),
).toBeVisible();
},
},
],
},
{
name: 'Visitor Auth - Space',
baseUrl: `https://gitbook.gitbook.io/gbo-va-space/`,
tests: [
{
name: 'First',
url: (() => {
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `first?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'Second',
url: (() => {
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `second?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
],
},
{
name: 'Visitor Auth - Collection',
baseUrl: `https://gitbook.gitbook.io/gbo-va-collection/`,
tests: [
{
name: 'Root',
url: (() => {
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'Primary (Space A)',
url: (() => {
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `v/spacea?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'Space B',
url: (() => {
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `v/spaceb?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'Space C',
url: (() => {
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `v/spacec?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
],
},
{
name: 'Visitor Auth - Space (custom domain)',
baseUrl: `https://test.gitbook.community/`,
tests: [
{
name: 'Root',
url: (() => {
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'First',
url: (() => {
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `first?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'Custom page',
url: (() => {
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `custom-page?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
{
name: 'Inner page',
url: (() => {
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
privateKey,
{
expiresIn: '24h',
},
);
return `custom-page/inner-page?jwt_token=${token}`;
})(),
run: waitForCookiesDialog,
},
],
},
{
name: 'Languages',
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
tests: allLocales.map((locale) => ({
name: locale,
url: getCustomizationURL({
internationalization: {
locale,
inherit: false,
},
}),
run: async (page) => {
const dialog = page.getByTestId('cookies-dialog');
await expect(dialog).toBeVisible();
},
})),
},
];
for (const testCase of testCases) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
test(testEntry.name, async ({ page, baseURL }) => {
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
const url = getContentTestURL(contentUrl.toString(), baseURL);
await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
}
if (testEntry.screenshot !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
fullPage: testEntry.fullPage ?? false,
});
}
});
}
});
}
/**
* Create a URL with customization settings.
*/
function getCustomizationURL(partial: Partial<CustomizationSettings>): string {
const encoded = rison.encode_object(partial);
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
return `?${searchParams.toString()}`;
}
+68
View File
@@ -0,0 +1,68 @@
const { withSentryConfig } = require('@sentry/nextjs');
module.exports = withSentryConfig(
{
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
},
webpack(config) {
config.resolve.fallback = {
...config.resolve.fallback,
// Required for `swagger2openapi` to work:
fs: false,
path: false,
http: false,
};
return config;
},
async headers() {
return [
// Cache all static assets for 1 year
{
source: '/~gitbook/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
}
]
}
},
{
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring',
// Don't hide source maps from generated client bundles
hideSourceMaps: false,
disableLogger: true,
automaticVercelMonitors: false,
},
);
+84 -34
View File
@@ -1,42 +1,92 @@
{
"name": "gitbook",
"version": "0.1.0",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@changesets/cli": "^2.27.12",
"turbo": "^2.5.0",
"vercel": "^39.3.0"
},
"packageManager": "bun@1.2.8",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.109.0"
},
"private": true,
"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",
"typecheck": "turbo run typecheck",
"format": "biome check --write ./",
"format:check": "biome check --diagnostic-level=error ./",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"changeset-version": "changeset version && bun run format",
"release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"clean": "turbo run clean"
"dev": "next dev",
"build": "next build",
"build:cloudflare": "next-on-pages",
"start": "next start",
"lint": "next lint",
"format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different",
"typecheck": "tsc --noEmit",
"unit": "bun test {src,packages}/**/*.test.ts",
"e2e": "playwright test",
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2"
},
"workspaces": ["packages/*"],
"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"
"workspaces": [
"packages/*"
],
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.46.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
"bun-types": "^1.0.7",
"classnames": "^2.5.1",
"content-security-policy-merger": "^1.0.0",
"framer-motion": "^10.16.14",
"js-cookie": "^3.0.5",
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"memoizee": "^0.4.15",
"next": "^14.1.3",
"next-themes": "^0.2.1",
"nuqs": "^1.15.4",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"react": "^18",
"react-dom": "^18",
"react-hotkeys-hook": "^4.4.1",
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.2.0",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
"url-join": "^5.0.0"
},
"devDependencies": {
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "^1.9.0",
"@cloudflare/workers-types": "^4.20231218.0",
"@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/katex": "^0.16.5",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/psi": "^4.1.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/rison": "^0.0.9",
"autoprefixer": "^10",
"eslint": "^8",
"eslint-config-next": "13.5.6",
"eslint-plugin-import": "^2.29.0",
"jsonwebtoken": "^9.0.2",
"postcss": "^8",
"prettier": "^3.0.3",
"psi": "^4.1.0",
"tailwindcss": "^3.4.0",
"typescript": "^5"
}
}
-3
View File
@@ -1,3 +0,0 @@
.wrangler
worker-configuration.d.ts
dist/
-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
-22
View File
@@ -1,22 +0,0 @@
# `@gitbook/cache-do`
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
### Performances
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
### Geo-distribution
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
### Concepts
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
**Location**: cache is distributed over 7 unique locations, one for each continent.
-35
View File
@@ -1,35 +0,0 @@
{
"name": "@gitbook/cache-do",
"type": "module",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
},
"./api": {
"types": "./dist/api.d.ts",
"development": "./src/api.ts",
"default": "./dist/api.js"
}
},
"version": "0.1.1",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "^3.112.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"release": "wrangler deploy",
"release:preview": "wrangler deploy && wrangler deploy --env preview"
},
"files": ["dist", "src", "bin", "data", "README.md", "CHANGELOG.md"]
}
-292
View File
@@ -1,292 +0,0 @@
import { DurableObject } from 'cloudflare:workers';
import { decode, encode } from '@msgpack/msgpack';
import { LRUMap } from 'lru_map';
export interface CacheObjectDescriptor {
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
}
/**
* Value stored in a chunked binary msgpack format.
* Stored under the key `prop.${key}.${index}`.
*/
interface CacheObjectProp<Value = unknown> {
value: Value;
expiresAt: number;
}
/**
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
*/
interface CacheObjectExp {
/** Key of the property */
k: string;
/** Number of chunks */
c: number;
}
/**
* Durable Object class being deployed as a distributed cache.
*/
export class CacheObject extends DurableObject {
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
/**
* Open a descriptor to access the cache object.
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
* One session is opened per request on the client side and used to perform multiple operations.
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
*/
public open(): CacheObjectDescriptor {
return {
get: async <Value = unknown>(key: string) => {
return this.get<Value>(key);
},
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
await this.set(key, value, expiresAt);
},
};
}
/**
* Get the value of a property.
*/
public async get<Value = unknown>(key: string) {
return this.logOperation({ operation: 'get', key }, async (setLog) => {
// Try the memory state first.
const memoryEntry = this.lru.get(key);
if (memoryEntry) {
setLog({ memory: true });
setLog({ memoryMatch: !!memoryEntry.match });
if (!memoryEntry.match) {
return;
}
const isExpired = memoryEntry.match.expiresAt < Date.now();
setLog({ memoryExpired: isExpired });
if (!isExpired) {
return memoryEntry.match.value as Value;
}
}
return await this.getFromStorage<Value>(key);
});
}
/**
* 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 });
});
}
/**
* 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) => {
const prop: CacheObjectProp<Value> = {
value,
expiresAt,
};
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,
};
await tx.put(getGCClockKey(key, expiresAt), clockValue);
await tx.put(entries);
const currentAlarm = await tx.getAlarm();
if (!currentAlarm) {
// Set an alarm to garbage collect all entries that have expired in 12h.
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
}
});
});
}
/**
* Purge all keys in the cache object.
*/
public async purge() {
return this.logOperation({ operation: 'purge' }, async (setLog) => {
const 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) {}
await this.reset();
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>();
for (const [key, exp] of entries) {
const timestamp = Number.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) {
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();
const 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 });
}
}
}
function getStoragePropKey(key: string): string {
return `prop.${key}.`;
}
function getStoragePropChunkKey(key: string, index: number): string {
return `${getStoragePropKey(key)}${index}`;
}
function getGCClockRootKey(timestamp: number): string {
return `exp.${timestamp}.`;
}
function getGCClockKey(key: string, expiresAt: number): string {
return `${getGCClockRootKey(expiresAt)}${key}`;
}
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
const buf = encode(value);
const entries: Record<string, Uint8Array> = {};
const chunks = chunkUint8Array(buf, 128 * 1024);
for (let index = 0; index < chunks.length; index++) {
entries[getStoragePropChunkKey(key, index)] = chunks[index];
}
return entries;
}
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
const chunks = Array.from(entries.entries())
.map(([key, value]) => {
const index = Number.parseInt(key.split('.').pop()!);
return [index, value] as const;
})
.sort(([a], [b]) => a - b)
.map(([, value]) => value);
if (chunks.length === 0) {
return;
}
const buf = mergeUint8Array(chunks);
return { value: decode(buf) as T, size: buf.length };
}
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
const chunks: Uint8Array[] = [];
for (let i = 0; i < input.length; i += chunkSize) {
chunks.push(input.slice(i, i + chunkSize));
}
return chunks;
}
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
-97
View File
@@ -1,97 +0,0 @@
import type { CacheObject } from './CacheObject';
export type CacheLocationId = ContinentCode;
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
/**
* Location hint for the CacheObject durable object.
*/
const doLocationHints: {
[key in CacheLocationId]: DurableObjectLocationHint;
} = {
AF: 'afr',
AS: 'apac',
NA: 'wnam',
SA: 'sam',
AN: 'oc',
EU: 'weur',
OC: 'oc',
};
/**
* Client to access a cache tag.
*/
export class CacheObjectStub {
private stub: DurableObjectStub<CacheObject>;
constructor(
/** Binding to the CacheObject durable object */
private doNamespace: DurableObjectNamespace<CacheObject>,
/** ID of the location to target */
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');
* ```
*/
async open() {
return await this.stub.open();
}
/**
* Get a value from the cache.
*/
async get<Value = unknown>(key: string) {
return (await this.stub.get(key)) as Value | undefined;
}
/**
* Set a value in the cache.
*/
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
return await this.stub.set(key, value, expiresAt);
}
/**
* Purge all keys in the cache tag.
*/
async purge() {
const keys = new Set<string>();
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 locationkeys = await cacheGroup.purge();
locationkeys.forEach((key) => keys.add(key));
})
);
return keys;
}
}
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
return `${locationId}:${tag}`;
}
-1
View File
@@ -1 +0,0 @@
export * from './CacheObjectStub';
-9
View File
@@ -1,9 +0,0 @@
import { WorkerEntrypoint } from 'cloudflare:workers';
export * from './CacheObject';
export default class Worker extends WorkerEntrypoint {
fetch() {
return new Response('Hello, world!');
}
}
-21
View File
@@ -1,21 +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,
"incremental": true,
"types": ["./.wrangler/types/runtime.d.ts"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": ["//"],
"tasks": {
"generate": {
"outputs": ["worker-configuration.d.ts"]
}
}
}
-27
View File
@@ -1,27 +0,0 @@
main = "./src/index.ts"
name = "gitbook-open-cache"
compatibility_date = "2024-09-02"
durable_objects.bindings = [
{name = "CACHE", class_name = "CacheObject"}
]
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/
-19
View File
@@ -1,19 +0,0 @@
# @gitbook/cache-tags
## 0.3.0
### Minor Changes
- 116575c: Improve typing of getComputedContentSourceCacheTags to match latest API specification
## 0.2.0
### Minor Changes
- f32bf1f: Export function `getCacheTagForURL` to easily get the cache tag for a URL.
## 0.1.0
### Minor Changes
- 05ffd0e: Initial version of the package
-3
View File
@@ -1,3 +0,0 @@
# `@gitbook/cache-tags`
Utility to generate cache tags for GitBook Open.
-25
View File
@@ -1,25 +0,0 @@
{
"name": "@gitbook/cache-tags",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.3.0",
"dependencies": {
"@gitbook/api": "*",
"assert-never": "^1.2.1"
},
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc -w"
},
"files": ["dist", "src", "README.md", "CHANGELOG.md"]
}
-209
View File
@@ -1,209 +0,0 @@
import type { ComputedContentSource } from '@gitbook/api';
import assertNever from 'assert-never';
/**
* Get a stringified cache tag for a given object.
*/
export function getCacheTag(
spec: /**
* All data related to a user
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'user';
user: string;
}
/**
* All data related to a space
*/
| {
tag: 'space';
space: string;
}
/**
* All data related to an integration.
*/
| {
tag: 'integration';
integration: string;
}
/**
* All data related to a change request
*/
| {
tag: 'change-request';
space: string;
changeRequest: string;
}
/**
* Immutable data related to a revision
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'revision';
space: string;
revision: string;
}
/**
* Immutable data related to a document
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'document';
space: string;
document: string;
}
/**
* Immutable data related to a computed document
* @deprecated - in v2, no tag as this is an immutable data
*/
| {
tag: 'computed-document';
space: string;
sourceType: string;
}
/**
* All data related to the URL of a content
*/
| {
tag: 'url';
hostname: string;
}
/**
* All data related to a site
*/
| {
tag: 'site';
site: string;
}
/**
* All data related to an OpenAPI spec
*/
| {
tag: 'openapi';
organization: string;
openAPISpec: string;
}
/**
* All data related to a translation
*/
| {
tag: 'translation';
organization: string;
translationSettings: string;
}
): string {
switch (spec.tag) {
case 'user':
return `user:${spec.user}`;
case 'url':
return `url:${spec.hostname}`;
case 'space':
return `space:${spec.space}`;
case 'change-request':
return `space:${spec.space}:change-request:${spec.changeRequest}`;
case 'revision':
return `space:${spec.space}:revision:${spec.revision}`;
case 'document':
return `space:${spec.space}:document:${spec.document}`;
case 'computed-document':
return `space:${spec.space}:computed-document:${spec.sourceType}`;
case 'site':
return `site:${spec.site}`;
case 'integration':
return `integration:${spec.integration}`;
case 'openapi':
return `organization:${spec.organization}:openapi:${spec.openAPISpec}`;
case 'translation':
return `organization:${spec.organization}:translation:${spec.translationSettings}`;
default:
assertNever(spec);
}
}
/**
* Get the cache tag for a given URL.
*/
export function getCacheTagForURL(url: string | URL) {
const parsedURL = url instanceof URL ? url : new URL(url);
return getCacheTag({
tag: 'url',
hostname: parsedURL.hostname,
});
}
/**
* Get the tags for a computed content source.
*/
export function getComputedContentSourceCacheTags(
inContext: {
spaceId: string;
organizationId: string;
},
source: ComputedContentSource
) {
const tags: string[] = [];
// We add the dependencies as tags, to ensure that the computed content is invalidated
// when the dependencies are updated.
const dependencies = Object.values(source.dependencies ?? {});
if (dependencies.length > 0) {
dependencies.forEach((dependency) => {
switch (dependency.ref.kind) {
case 'space':
tags.push(
getCacheTag({
tag: 'space',
space: dependency.ref.space,
})
);
break;
case 'openapi':
tags.push(
getCacheTag({
tag: 'openapi',
organization: inContext.organizationId,
openAPISpec: dependency.ref.spec,
})
);
break;
case 'translation-language':
tags.push(
getCacheTag({
tag: 'translation',
organization: inContext.organizationId,
translationSettings: dependency.ref.translationSettings,
})
);
break;
default:
// Do not throw for unknown dependency types
// as it might mean we are lacking behind the API version
break;
}
});
} else {
// Push a dummy tag, as the v1 is only using the first tag
tags.push(
getCacheTag({
tag: 'computed-document',
space: inContext.spaceId,
sourceType: source.type,
})
);
}
// We invalidate the computed content when a new version of the integration is deployed.
if (source.type.startsWith('integration:')) {
const integration = source.type.split(':')[1];
tags.push(
getCacheTag({
tag: 'integration',
integration,
})
);
}
return tags;
}
-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-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
-1
View File
@@ -1 +0,0 @@
dist/
-25
View File
@@ -1,25 +0,0 @@
# @gitbook/colors
## 0.3.2
### Patch Changes
- cdffd7c: Desaturate text colors by decreasing chroma for the last steps of the color scale
## 0.3.1
### Patch Changes
- fb90eb0: Reduce chroma of first color scale step
## 0.3.0
### Minor Changes
- 4f0a772: Override tint lightness if supplied color is out of bounds
## 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.
-21
View File
@@ -1,21 +0,0 @@
{
"name": "@gitbook/colors",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.3.2",
"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';
-422
View File
@@ -1,422 +0,0 @@
import { DARK_BASE, DEFAULT_TINT_COLOR, LIGHT_BASE } 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));
let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light;
if (mixColor && mix?.ratio && mix.ratio > 0) {
// If defined, we mix in a (tiny) bit of the mix color with the base color.
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;
}
if (
(darkMode && baseColor.L < backgroundColor.L) ||
(!darkMode && baseColor.L > backgroundColor.L)
) {
// If the supplied color is outside of our lightness bounds, use the supplied color's lightness.
// This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look.
const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L;
backgroundColor.L = baseColor.L;
// At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out.
const amplifier = 1;
mapping = mapping.map((step, index) =>
index < 9 ? step + step * amplifier * difference : step
);
}
const result = [];
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 === 9 ? 1 : index * 0.05;
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-jsx",
"incremental": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -1 +1 @@
dist/
index.ts
-13
View File
@@ -1,13 +0,0 @@
# @gitbook/emoji-codepoints
## 0.2.0
### Minor Changes
- 57adb3e: Second release to fix publishing with changeset
## 0.1.0
### Minor Changes
- 5f8a8fe: Initial release
+4 -5
View File
@@ -1,5 +1,4 @@
import fs from 'node:fs';
import path from 'node:path';
import fs from 'fs';
import emojisRaws from 'emoji-assets/emoji.json';
interface EmojiData {
@@ -17,11 +16,11 @@ Object.entries(emojis).forEach(([key, value]) => {
if (emoji && key !== emoji) {
output[key] = emoji;
} else if (!emoji) {
console.log('No emoji for', key);
}
});
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
fs.writeFileSync(
path.resolve(__dirname, 'dist/index.ts'),
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`
'index.ts',
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
);
+2 -4
View File
@@ -1,15 +1,13 @@
{
"name": "@gitbook/emoji-codepoints",
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
"version": "0.2.0",
"private": true,
"exports": "./dist/index.ts",
"exports": "./index.ts",
"dependencies": {},
"devDependencies": {
"emoji-assets": "^8.0.0"
},
"scripts": {
"generate": "bun ./build.ts",
"clean": "rm -rf ./dist"
"postinstall": "bun ./build.ts"
}
}
-11
View File
@@ -1,11 +0,0 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
# Symbolic links
public
-55
View File
@@ -1,55 +0,0 @@
# gitbook-v2
## 0.2.4
### Patch Changes
- 4234289: Fix incoming URL for requests that were proxied
- Updated dependencies [116575c]
- @gitbook/cache-tags@0.3.0
## 0.2.3
### Patch Changes
- 5b2bf82: Use stable site URL data for route rewrite in the middleware
## 0.2.2
### Patch Changes
- 54ee014: Add initial support for loading custom fonts
- bba2e52: Fix site redirects when it includes a section/variant path
## 0.2.1
### Patch Changes
- Updated dependencies [f32bf1f]
- @gitbook/cache-tags@0.2.0
## 0.2.0
### Minor Changes
- 76c7974: Add route to revalidate cached data
## 0.1.2
### Patch Changes
- 05ffd0e: Improving data cache management for computed content
- Updated dependencies [05ffd0e]
- @gitbook/cache-tags@0.1.0
## 0.1.1
### Patch Changes
- 3e11678: fix: lost section groups
## 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.
-60
View File
@@ -1,60 +0,0 @@
// @ts-check
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
// This is needed to throw "forbidden" when the api token expired during revalidation
authInterrupts: true,
// We don't use 'use cache' as it's not supported in OpenNext yet
// and instead we scope `unstable_cache` to the parameters of the function
useCache: false,
// Content is fully static, we can cache it in the session memory cache for a long time
staleTimes: {
dynamic: 3600, // 1 hour
static: 3600, // 1 hour
},
},
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
// GitBook envs
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
GITBOOK_URL: process.env.GITBOOK_URL,
GITBOOK_API_TOKEN: process.env.GITBOOK_API_TOKEN,
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_SECRET: process.env.GITBOOK_SECRET,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_FONTS_URL: process.env.GITBOOK_FONTS_URL,
GITBOOK_RUNTIME: process.env.GITBOOK_RUNTIME,
// Next.js envs
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
// Used to detect if the app is running in V2 mode
GITBOOK_V2: 'true',
},
assetPrefix: process.env.GITBOOK_ASSETS_PREFIX,
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.gitbook.io',
},
],
},
};
export default nextConfig;
-18
View File
@@ -1,18 +0,0 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
import doQueue from '@opennextjs/cloudflare/overrides/queue/do-queue';
import doShardedTagCache from '@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache';
export default defineCloudflareConfig({
incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
tagCache: doShardedTagCache({
baseShardSize: 12,
regionalCache: true,
shardReplication: {
numberOfSoftReplicas: 2,
numberOfHardReplicas: 1,
},
}),
queue: doQueue,
});
-35
View File
@@ -1,35 +0,0 @@
{
"name": "gitbook-v2",
"version": "0.2.4",
"private": true,
"dependencies": {
"next": "canary",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@7b3b305",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
"scripts": {
"generate": "rm -rf ./public && cp -r ../gitbook/public ./public",
"dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare build",
"dev:v2:cloudflare": "wrangler dev --port 8771",
"unit": "bun test",
"typecheck": "tsc --noEmit"
}
}
@@ -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,36 +0,0 @@
import {
SitePage,
generateSitePageMetadata,
generateSitePageViewport,
} from '@/components/SitePage';
import { type RouteParams, getDynamicSiteContext, getPagePathFromParams } from '@v2/app/utils';
import type { 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 }} />;
}
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 = await props.params;
const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
context,
pageParams: { pathname },
});
}
@@ -1,44 +0,0 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function SiteDynamicLayout({
params,
children,
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
return (
<CustomizationRootLayout forcedTheme={forcedTheme} customization={context.customization}>
<SiteLayout
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -1,12 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveIcon } from '@/routes/icon';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveIcon(context, request);
}
@@ -1,13 +0,0 @@
import type { NextRequest } from 'next/server';
import type { PageIdParams } from '@/components/SitePage';
import { serveOGImage } from '@/routes/ogimage';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const { context } = await getDynamicSiteContext(await params);
return serveOGImage(context, await params);
}
@@ -1,12 +0,0 @@
import { PDFRootLayout } from '@/components/PDF';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export default async function RootLayout(props: {
params: Promise<RouteLayoutParams>;
children: React.ReactNode;
}) {
const { params, children } = props;
const { context } = await getDynamicSiteContext(await params);
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
}
@@ -1,20 +0,0 @@
import { PDFPage, generatePDFMetadata } from '@/components/PDF';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
export async function generateMetadata({
params,
}: {
params: Promise<RouteLayoutParams>;
}) {
const { context } = await getDynamicSiteContext(await params);
return generatePDFMetadata(context);
}
export default async function Page(props: {
params: Promise<RouteLayoutParams>;
searchParams: Promise<{ [key: string]: string }>;
}) {
const { params, searchParams } = props;
const { context } = await getDynamicSiteContext(await params);
return <PDFPage context={context} searchParams={await searchParams} />;
}
@@ -1,5 +0,0 @@
import { SitePageNotFound } from '@/components/SitePage';
export default async function NotFound() {
return <SitePageNotFound />;
}
@@ -1,38 +0,0 @@
import {
SitePage,
generateSitePageMetadata,
generateSitePageViewport,
} from '@/components/SitePage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { Metadata, Viewport } from 'next';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} />;
}
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 },
});
}
@@ -1,41 +0,0 @@
import { CustomizationRootLayout } from '@/components/RootLayout';
import {
SiteLayout,
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
interface SiteStaticLayoutProps {
params: Promise<RouteLayoutParams>;
}
export default async function SiteStaticLayout({
params,
children,
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
return (
<CustomizationRootLayout customization={context.customization}>
<SiteLayout
context={context}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SiteLayout>
</CustomizationRootLayout>
);
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
const { context } = await getStaticSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const { context } = await getStaticSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveLLMsTxt } from '@/routes/llms';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context, { withMarkdownPages: true });
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRobotsTxt } from '@/routes/robots';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { servePagesSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return servePagesSitemap(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveRootSitemap } from '@/routes/sitemap';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveRootSitemap(context);
}
@@ -1,14 +0,0 @@
import type { NextRequest } from 'next/server';
import { serveIcon } from '@/routes/icon';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveIcon(context, request);
}
@@ -1,11 +0,0 @@
import { servePageMarkdown } from '@/routes/markdownPage';
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@v2/app/utils';
import type { NextRequest } from 'next/server';
export const dynamic = 'force-static';
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
const { context } = await getStaticSiteContext(await params);
const pathname = getPagePathFromParams(await params);
return servePageMarkdown(context, pathname);
}
@@ -1,15 +0,0 @@
import type { NextRequest } from 'next/server';
import type { PageIdParams } from '@/components/SitePage';
import { serveOGImage } from '@/routes/ogimage';
import { type RouteLayoutParams, getStaticSiteContext } from '@v2/app/utils';
export const dynamic = 'force-static';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const { context } = await getStaticSiteContext(await params);
return serveOGImage(context, await params);
}
-104
View File
@@ -1,104 +0,0 @@
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import type { SiteAPIToken } from '@gitbook/api';
import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation';
import rison from 'rison';
export type RouteParamMode = 'url-host' | 'url';
export type RouteLayoutParams = {
mode: string;
/** URL encoded site URL */
siteURL: string;
/** URL and Rison encoded site data from getPublishedContentByUrl */
siteData: string;
};
export type RouteParams = RouteLayoutParams & {
pagePath: string;
};
/**
* Get the static context when rendering statically a site.
*/
export async function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
// For static routes, we check the expiration of the JWT token
// as the route might be revalidated after expiration
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
forbidden();
}
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
* Get the site context when rendering dynamically.
* The context will depend on the request.
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = getSiteURLDataFromParams(params);
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
siteURLData,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
};
}
/**
* Get the decoded page path from the params.
*/
export function getPagePathFromParams(params: RouteParams) {
const decoded = decodeURIComponent(params.pagePath);
return decoded;
}
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';
}
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
}
-40
View File
@@ -1,40 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server';
import {
GITBOOK_API_PUBLIC_URL,
GITBOOK_API_TOKEN,
GITBOOK_API_URL,
GITBOOK_APP_URL,
GITBOOK_ASSETS_URL,
GITBOOK_DISABLE_TRACKING,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_SECRET,
GITBOOK_URL,
GITBOOK_USER_AGENT,
} from '@v2/lib/env';
/**
* Output the public environment variables for this deployment
*/
export async function GET(_req: NextRequest) {
return NextResponse.json({
GITBOOK_URL,
GITBOOK_APP_URL,
GITBOOK_API_URL,
GITBOOK_API_PUBLIC_URL,
GITBOOK_ASSETS_URL,
GITBOOK_FONTS_URL,
GITBOOK_ICONS_URL,
GITBOOK_USER_AGENT,
GITBOOK_INTEGRATIONS_HOST,
GITBOOK_DISABLE_TRACKING,
// Secret envs
GITBOOK_SECRET: !!GITBOOK_SECRET,
GITBOOK_API_TOKEN: !!GITBOOK_API_TOKEN,
GITBOOK_IMAGE_RESIZE_SIGNING_KEY: !!GITBOOK_IMAGE_RESIZE_SIGNING_KEY,
});
}
@@ -1,35 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server';
import { withVerifySignature } from '@v2/lib/routes';
import { revalidateTag } from 'next/cache';
interface JsonBody {
tags: string[];
}
/**
* Revalidate cached data based on tags.
* The body should be a JSON with { tags: string[] }
*/
export async function POST(req: NextRequest) {
return withVerifySignature<JsonBody>(req, async (body) => {
if (!body.tags || !Array.isArray(body.tags)) {
return NextResponse.json(
{
error: 'tags must be an array',
},
{ status: 400 }
);
}
body.tags.forEach((tag) => {
// biome-ignore lint/suspicious/noConsole: we want to log here
console.log(`Revalidating tag: ${tag}`);
revalidateTag(tag);
});
return NextResponse.json({
success: true,
});
});
}
@@ -1,59 +0,0 @@
import {
type GitBookBaseContext,
type GitBookSpaceContext,
fetchSpaceContextByIds,
} from '@v2/lib/context';
import { createDataFetcher } from '@v2/lib/data';
import { createLinker } from '@v2/lib/links';
import { getAPITokenFromMiddleware } from '@v2/lib/middleware';
export type SpacePDFRouteParams = {
spaceId: string;
changeRequestId?: string;
revisionId?: string;
};
export async function getSpacePDFContext(
params: SpacePDFRouteParams
): Promise<GitBookSpaceContext> {
const { spaceId } = params;
const apiToken = await getAPITokenFromMiddleware();
const basePath = getPDFRoutePath(params);
const linker = createLinker({
spaceBasePath: basePath,
siteBasePath: basePath,
});
const dataFetcher = createDataFetcher({
apiToken: apiToken,
});
const baseContext: GitBookBaseContext = {
linker,
dataFetcher,
};
return await fetchSpaceContextByIds(baseContext, {
space: spaceId,
shareKey: undefined,
changeRequest: params.changeRequestId,
revision: params.revisionId,
});
}
function getPDFRoutePath(params: SpacePDFRouteParams) {
let path = `/~space/${params.spaceId}`;
if (params.changeRequestId) {
path += `/~/changes/${params.changeRequestId}`;
}
if (params.revisionId) {
path += `/~/revisions/${params.revisionId}`;
}
path += '~gitbook/pdf';
return path;
}
@@ -1,2 +0,0 @@
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
export default RootLayout;
@@ -1,4 +0,0 @@
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
export default PDFPage;
export { generateMetadata };
@@ -1,2 +0,0 @@
import RootLayout from '@v2/app/~space/[spaceId]/~gitbook/pdf/layout';
export default RootLayout;
@@ -1,4 +0,0 @@
import PDFPage, { generateMetadata } from '@v2/app/~space/[spaceId]/~gitbook/pdf/page';
export default PDFPage;
export { generateMetadata };
@@ -1,12 +0,0 @@
import { PDFRootLayout } from '@/components/PDF';
import { type SpacePDFRouteParams, getSpacePDFContext } from '@v2/app/~space/[spaceId]/pdf';
export default async function RootLayout(props: {
params: Promise<SpacePDFRouteParams>;
children: React.ReactNode;
}) {
const { params, children } = props;
const context = await getSpacePDFContext(await params);
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
}
@@ -1,20 +0,0 @@
import { PDFPage, generatePDFMetadata } from '@/components/PDF';
import { type SpacePDFRouteParams, getSpacePDFContext } from '@v2/app/~space/[spaceId]/pdf';
export async function generateMetadata({
params,
}: {
params: Promise<SpacePDFRouteParams>;
}) {
const context = await getSpacePDFContext(await params);
return generatePDFMetadata(context);
}
export default async function Page(props: {
params: Promise<SpacePDFRouteParams>;
searchParams: Promise<{ [key: string]: string }>;
}) {
const { params, searchParams } = props;
const context = await getSpacePDFContext(await params);
return <PDFPage context={context} searchParams={await searchParams} />;
}
-390
View File
@@ -1,390 +0,0 @@
import { getSiteStructureSections } from '@/lib/sites';
import type {
ChangeRequest,
PublishedSiteContent,
RevisionPage,
RevisionPageDocument,
Site,
SiteCustomizationSettings,
SiteIntegrationScript,
SiteSection,
SiteSectionGroup,
SiteSpace,
SiteStructure,
Space,
} from '@gitbook/api';
import {
type GitBookDataFetcher,
createDataFetcher,
getDataOrNull,
throwIfDataError,
} from '@v2/lib/data';
import { notFound } from 'next/navigation';
import { assert } from 'ts-essentials';
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker } from './links';
/**
* Data about the site URL. Provided by the middleware.
* These data are stable between pages in the same site space.
*/
export type SiteURLData = Pick<
PublishedSiteContent,
| 'organization'
| 'apiToken'
| 'site'
| 'siteSpace'
| 'space'
| 'revision'
| 'changeRequest'
| 'shareKey'
| 'siteSection'
| 'siteBasePath'
| 'basePath'
> & {
/**
* Identifier used for image resizing.
*/
imagesContextId: string;
};
/**
* Generic context when rendering content.
*/
export type GitBookBaseContext = {
/**
* Data fetcher to fetch data from GitBook.
*/
dataFetcher: GitBookDataFetcher;
/**
* Linker to generate links in the current space.
*/
linker: GitBookLinker;
/**
* 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[];
};
/**
* Context when rendering a page.
*/
export type GitBookPageContext = (GitBookSpaceContext | GitBookSiteContext) & {
page: RevisionPageDocument;
};
/**
* Get the base context for a request on a site.
*/
export function getBaseContext(input: {
siteURL: URL | string;
siteURLData: SiteURLData;
urlMode: 'url' | 'url-host';
}) {
const { urlMode, siteURLData } = input;
const siteURL = typeof input.siteURL === 'string' ? new URL(input.siteURL) : input.siteURL;
const dataFetcher = createDataFetcher({
apiToken: siteURLData.apiToken ?? null,
});
const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined;
const linker =
urlMode === 'url-host'
? createLinker({
host: siteURL.host,
siteBasePath: siteURLData.siteBasePath,
spaceBasePath: siteURLData.basePath,
})
: createLinker({
protocol: gitbookURL?.protocol,
host: gitbookURL?.host,
siteBasePath: `/url/${siteURL.host}${siteURLData.siteBasePath}`,
spaceBasePath: `/url/${siteURL.host}${siteURLData.basePath}`,
});
if (urlMode === 'url') {
// Create link in the same format for links to other sites/sections.
linker.toLinkForContent = (rawURL: string) => {
const urlObject = new URL(rawURL);
return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`;
};
}
const imageResizer = createImageResizer({
imagesContextId: siteURLData.imagesContextId,
// To ensure image resizing work for proxied sites,
// we serve images from the root of the site.
linker: linker,
});
return {
dataFetcher,
linker,
imageResizer,
};
}
/**
* Fetch the context of a site using the resolution of a URL
*/
export async function fetchSiteContextByURLLookup(
baseContext: GitBookBaseContext,
data: SiteURLData
): Promise<GitBookSiteContext> {
return await fetchSiteContextByIds(baseContext, {
organization: data.organization,
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
space: data.space,
shareKey: data.shareKey,
changeRequest: data.changeRequest,
revision: data.revision,
});
}
/**
* 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;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const [{ site: orgSite, structure: siteStructure, customizations, scripts }, spaceContext] =
await Promise.all([
throwIfDataError(
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
? parseSiteSectionsAndGroups(siteStructure, ids.siteSection)
: null;
const siteSpace = (
siteStructure.type === 'siteSpaces' && siteStructure.structure
? siteStructure.structure
: sections?.current.siteSpaces
)?.find((siteSpace) => siteSpace.id === ids.siteSpace);
if (!siteSpace) {
throw new Error('Site space not found');
}
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,
};
}
/**
* 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([
throwIfDataError(
dataFetcher.getSpace({
spaceId: ids.space,
shareKey: ids.shareKey,
})
),
ids.changeRequest
? getDataOrNull(
dataFetcher.getChangeRequest({
spaceId: ids.space,
changeRequestId: ids.changeRequest,
})
)
: null,
]);
if (ids.changeRequest && !changeRequest) {
// When trying to render a change request with an invalid / non-existing ID,
// we should return a 404.
notFound();
}
const revisionId = ids.revision ?? changeRequest?.revision ?? space.revision;
const pages = await getDataOrNull(
dataFetcher.getRevisionPages({
spaceId: ids.space,
revisionId,
// We only care about the Git metadata when the Git sync is enabled,
// otherwise we can optimize performance by not fetching it
metadata: !!space.gitSync,
}),
// When trying to render a revision with an invalid / non-existing ID,
// we should handle gracefully the 404 and throw notFound.
ids.revision ? [404] : undefined
);
if (!pages) {
notFound();
}
return {
...baseContext,
organizationId: space.organization,
space,
pages,
changeRequest,
revisionId,
shareKey: ids.shareKey,
};
}
/**
* Check if the context is the root one for a site.
* Meaning we are on the default section / space.
*/
export function checkIsRootSiteContext(context: GitBookSiteContext): boolean {
const { structure } = context;
switch (structure.type) {
case 'sections': {
return getSiteStructureSections(structure, { ignoreGroups: true }).some(
(structure) =>
structure.default &&
structure.id === context.sections?.current.id &&
structure.siteSpaces.some(
(siteSpace) => siteSpace.default && siteSpace.id === context.siteSpace.id
)
);
}
case 'siteSpaces': {
return structure.structure.some(
(siteSpace) => siteSpace.default && siteSpace.id === context.siteSpace.id
);
}
}
}
function parseSiteSectionsAndGroups(structure: SiteStructure, siteSectionId: string) {
const sectionsAndGroups = getSiteStructureSections(structure, { ignoreGroups: false });
const section = parseCurrentSection(structure, siteSectionId);
assert(section, 'A section must be defined when there are multiple sections');
return { list: sectionsAndGroups, current: section } satisfies SiteSections;
}
function parseCurrentSection(structure: SiteStructure, siteSectionId: string) {
const sections = getSiteStructureSections(structure, { ignoreGroups: true });
return sections.find((section) => section.id === siteSectionId);
}
File diff suppressed because it is too large Load Diff
@@ -1,25 +0,0 @@
import { getCloudflareContext as getCloudflareContextOpenNext } from '@opennextjs/cloudflare';
import { GITBOOK_RUNTIME } from '../env';
/**
* Return the Cloudflare context or null when not running in Cloudflare.
*/
export function getCloudflareContext() {
if (GITBOOK_RUNTIME !== 'cloudflare') {
return null;
}
return getCloudflareContextOpenNext();
}
/**
* Return an object representing the current request.
*/
export function getCloudflareRequestGlobal() {
const context = getCloudflareContext();
if (!context) {
return null;
}
return context.cf;
}
-115
View File
@@ -1,115 +0,0 @@
import { GitBookAPIError } from '@gitbook/api';
import type { DataFetcherErrorData, DataFetcherResponse } from './types';
export class DataFetcherError extends Error {
constructor(
message: string,
public readonly code: number
) {
super(message);
}
}
/**
* Throw an error if the response contains an error.
*/
export function throwIfDataError<T>(response: DataFetcherResponse<T>): T;
export function throwIfDataError<T>(response: Promise<DataFetcherResponse<T>>): Promise<T>;
export function throwIfDataError<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>
): T | Promise<T> {
if (response instanceof Promise) {
return response.then((result) => throwIfDataError(result));
}
if (response.error) {
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
}
/**
* Get the data from the response or null if there is an "Not found" error.
*/
export function getDataOrNull<T>(
response: DataFetcherResponse<T>,
ignoreErrors?: number[]
): T | null;
export function getDataOrNull<T>(
response: Promise<DataFetcherResponse<T>>,
ignoreErrors?: number[]
): Promise<T | null>;
export function getDataOrNull<T>(
response: DataFetcherResponse<T> | Promise<DataFetcherResponse<T>>,
ignoreErrors: number[] = [404]
): T | null | Promise<T | null> {
if (response instanceof Promise) {
return response.then((result) => getDataOrNull(result, ignoreErrors));
}
if (response.error) {
if (ignoreErrors.includes(response.error.code)) return null;
throw new DataFetcherError(response.error.message, response.error.code);
}
return response.data;
}
/**
* Ignore error for an API or data call.
*/
export async function ignoreDataThrownError<T>(promise: Promise<T>): Promise<T | null> {
try {
return await promise;
} catch (error) {
getExposableError(error as Error);
return null;
}
}
/**
* Ignore all errors for an API or data call.
*/
export async function ignoreAllThrownError<T>(promise: Promise<T>): Promise<T | null> {
try {
return await promise;
} catch (error) {
console.warn('Ignoring error', error);
return null;
}
}
/**
* Wrap an async execution to handle errors and return a DataFetcherResponse.
*/
export async function wrapDataFetcherError<T>(
fn: () => Promise<T>
): Promise<DataFetcherResponse<T>> {
try {
return { data: await fn() };
} catch (error) {
return {
error: getExposableError(error as Error),
};
}
}
/**
* Get a data fetcher exposable error from a JS error.
*/
export function getExposableError(error: Error): DataFetcherErrorData {
if (error instanceof GitBookAPIError) {
return {
code: error.code,
message: error.errorMessage,
};
}
if (error instanceof DataFetcherError) {
return {
code: error.code,
message: error.message,
};
}
throw error;
}
@@ -1,8 +0,0 @@
export * from './api';
export * from './types';
export * from './pages';
export * from './urls';
export * from './errors';
export * from './lookup';
export * from './proxy';
export * from './visitor';
-140
View File
@@ -1,140 +0,0 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls';
/**
* Lookup a content by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function getPublishedContentByURL(input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
apiToken: string | null;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const api = await apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'getPublishedContentByURL',
name: alternative.url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url: alternative.url,
visitorAuthToken: input.visitorAuthToken ?? undefined,
redirectOnError: input.redirectOnError,
// As this endpoint is cached by our API, we version the request
// to void getting stale data with missing properties.
// this could be improved by ensuring our API cache layer is versioned
// or invalidated when needed
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{
signal,
}
)
)
);
if (callResult.error) {
if (alternative.primary) {
// We only return an error for the primary alternative (full URL),
// as other parts could result in errors due to the URL being incomplete (share links, etc).
return { error: callResult.error };
}
return null;
}
const {
data: { data },
} = callResult;
if ('redirect' in data) {
if (alternative.primary) {
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {
if (data.target === 'content') {
const redirect = new URL(data.redirect);
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
data.redirect = redirect.toString();
} else {
const redirect = new URL(data.redirect);
if (redirect.searchParams.has('location')) {
redirect.searchParams.set(
'location',
joinPath(
redirect.searchParams.get('location') ?? '',
alternative.extraPath
)
);
data.redirect = redirect.toString();
}
}
}
return { data };
}
return null;
}
/**
* We use the following criteria to determine if the lookup result is the right one:
* - the primary alternative was resolved (because that's the longest or most inclusive path)
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
*
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
*/
if (alternative.primary || ('site' in data && data.complete)) {
const changeRequest = data.changeRequest ?? lookup.changeRequest;
const revision = data.revision ?? lookup.revision;
const siteResult: PublishedSiteContentLookup = {
...data,
canonicalUrl: joinPathWithBaseURL(data.canonicalUrl, alternative.extraPath),
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}),
...(revision ? { revision } : {}),
};
return { data: siteResult };
}
return null;
});
if (!result) {
return {
error: {
code: 404,
message: 'No content found',
},
};
}
if (result.error) {
return {
error: getExposableError(result.error),
};
}
return {
data: result.data,
};
}
@@ -1,52 +0,0 @@
import { describe, expect, it, mock } from 'bun:test';
import { AsyncLocalStorage } from 'node:async_hooks';
import { withCacheKey, withoutConcurrentExecution } from './memoize';
describe('withoutConcurrentExecution', () => {
it('should memoize the function based on the cache key', async () => {
const fn = mock(async (a: number, b: number) => a + b);
const memoized = withoutConcurrentExecution(() => null, fn);
const p1 = memoized('c1', 1, 2);
const p2 = memoized('c1', 1, 2);
const p3 = memoized('c3', 2, 3);
expect(await p1).toBe(await p2);
expect(await p1).not.toBe(await p3);
expect(fn.mock.calls.length).toBe(2);
});
it('should support caching per request', async () => {
const fn = mock(async () => Math.random());
const request1 = { id: 'request1' };
const request2 = { id: 'request2' };
const requestContext = new AsyncLocalStorage<{ id: string }>();
const memoized = withoutConcurrentExecution(() => requestContext.getStore(), fn);
// Both in the same request
const promise1 = requestContext.run(request1, () => memoized('c1'));
const promise2 = requestContext.run(request1, () => memoized('c1'));
// In a different request
const promise3 = requestContext.run(request2, () => memoized('c1'));
expect(await promise1).toBe(await promise2);
expect(await promise1).not.toBe(await promise3);
expect(fn.mock.calls.length).toBe(2);
});
});
describe('withCacheKey', () => {
it('should wrap the function by passing the cache key', async () => {
const fn = mock(
async (cacheKey: string, arg: { a: number; b: number }, c: number) =>
`${cacheKey}, result=${arg.a + arg.b + c}`
);
const memoized = withCacheKey(fn);
expect(await memoized({ a: 1, b: 2 }, 4)).toBe('[[["a",1],["b",2]],4], result=7');
expect(fn.mock.calls.length).toBe(1);
});
});

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