Compare commits

..

1 Commits

Author SHA1 Message Date
Valentino Hudhra 81af6bb077 Replace header link API type 2024-12-06 10:51:39 +01:00
346 changed files with 6664 additions and 76252 deletions
-1
View File
@@ -1 +0,0 @@
1.2.1
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix an issue with the cookie banner buttons being non responsive
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/cache-do': patch
---
Disable cloudflare observability in production
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Update styling of search+ask modal
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix a regression in variant drop-down caused by missing z-index.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Add scroll margin to the top when there are sections
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Smoother tab transition for sections
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the active site section indicator appeared above any dropdowns.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Don't adjust fallback font for mono font.
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Fixed scalar api client routing
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Optimize performances by using new API endpoint for fetching site data.
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Bump @scalar/api-client-react version
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Support Integers in Response example
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Restyle hint blocks
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix security issue with image resizing that could be used for phishing
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
bumped the scalar api client dependency
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Bumped scalar api client pacakge
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the space dropdown was shown under the site sections in Safari.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix - whitespace added to site section tabs with icons.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Add icons to sections
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
style: adds missing scalar css variables
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the space dropdown could appear behind the header.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix the styling of site section tabs on smaller screens.
+1 -1
View File
@@ -12,7 +12,7 @@ runs:
# Cache browser binaries, cache key is based on Playwright version and OS
- name: 🧰 Cache Playwright browser binaries
uses: actions/cache@v4
uses: actions/cache@v3
id: playwright-cache
with:
path: '~/.cache/ms-playwright'
-18
View File
@@ -1,18 +0,0 @@
name: 'Setup Bun'
description: 'Install Bun and cache dependencies'
runs:
using: 'composite'
steps:
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
# and impacting bandwidth there.
- name: Cache bun
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
+56 -191
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
env:
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
jobs:
deploy:
name: Deploy to Cloudflare Pages
@@ -18,17 +18,28 @@ jobs:
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Cache Next.js build
uses: actions/cache@v3
with:
path: |
${{ github.workspace }}/.next/cache
# Generate a new cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# If source files changed but packages didn't, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
@@ -39,14 +50,12 @@ jobs:
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build Next.js with next-on-pages
run: bun run turbo build:cloudflare
run: bun run build:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.11.0
@@ -75,159 +84,26 @@ jobs:
-H "Accept: application/vnd.github.v3+json" \
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
deploy-v2-vercel:
name: Deploy v2 to Vercel
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "VERCEL_ENVIRONMENT=production" >> $GITHUB_ENV
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
if: startsWith(github.ref, 'refs/heads/main')
- name: Sets env vars for preview
run: |
echo "VERCEL_ENVIRONMENT=preview" >> $GITHUB_ENV
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Pull Vercel Environment Information
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: bun run vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- name: Deploy Project Artifacts to Vercel
id: deploy
run: |
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
deploy-v2-cloudflare:
name: Deploy v2 to Cloudflare Worker
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
issues: write
pull-requests: write
checks: write
statuses: write
outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Sets env vars for production
run: |
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
echo "GITBOOK_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 worker
run: bun run turbo build:v2:cloudflare
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy
name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.11.0
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: ./
wranglerVersion: '3.82.0'
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
- name: Outputs
run: |
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
comment-deployments:
runs-on: ubuntu-latest
name: Comment Deployments
if: always() && !startsWith(github.ref, 'refs/heads/main')
needs:
- deploy
- deploy-v2-vercel
- deploy-v2-cloudflare
steps:
- name: Find GitHub Comment
uses: peter-evans/find-comment@v3
id: fc
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments'
body-includes: GitBook Preview
- name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
Summary of the deployments:
### Version 1 (production)
| Version | URL | Status |
| --- | --- | --- |
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
### Version 2 (experimental)
| Version | URL | Status |
| --- | --- | --- |
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
### Test content
| Site | v1 | v2 |
| --- | --- | --- |
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com) |
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open) |
**GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
@@ -236,8 +112,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
@@ -245,7 +123,7 @@ jobs:
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
@@ -253,31 +131,6 @@ jobs:
name: playwright-test-results
path: packages/gitbook/test-results/
retention-days: 3
visual-testing-customers:
runs-on: ubuntu-latest
name: Visual Testing Customers
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e-customers
env:
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
ARGOS_BUILD_NAME: 'customers'
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results-customers
path: packages/gitbook/test-results/
retention-days: 3
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
@@ -285,8 +138,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -294,7 +149,7 @@ jobs:
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
@@ -302,8 +157,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -315,8 +172,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -328,8 +187,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -340,14 +201,16 @@ jobs:
runs-on: ubuntu-latest
name: Build (Open Source)
env:
NPM_TOKEN_READONLY: ''
NPMRC_FONT_AWESOME_TOKEN: ''
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build
@@ -357,8 +220,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+8 -4
View File
@@ -17,8 +17,10 @@ jobs:
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -43,8 +45,10 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
-4
View File
@@ -1,6 +1,5 @@
.next
.vercel
.open-next
# Generated
packages/emoji-codepoints/index.ts
@@ -9,6 +8,3 @@ packages/icons/src/data/*.json
# Build files
dist/
README.md
CHANGELOG.md
+2 -24
View File
@@ -35,10 +35,8 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >=20.6)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
- Node.js (Version: >=18.x)
- Bun
### Set up
@@ -80,26 +78,6 @@ Any published GitBook site can be accessed through your local development instan
All pull-requests will be tested against both visual and performances testing to prevent regressions.
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
-5905
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes]
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
+7 -16
View File
@@ -2,24 +2,20 @@
"name": "gitbook",
"version": "0.1.0",
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.4.1-canary.2",
"vercel": "^39.3.0"
"turbo": "^2.1.2"
},
"packageManager": "bun@1.1.18",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1"
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
},
"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",
"build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
@@ -27,7 +23,6 @@
"format:check": "prettier ./ --ignore-unknown --list-different",
"unit": "turbo run unit",
"e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers",
"changeset": "changeset",
"release": "turbo run release && changeset publish",
"release:preview": "turbo run release:preview",
@@ -36,9 +31,5 @@
},
"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"
}
]
}
-6
View File
@@ -1,11 +1,5 @@
# @gitbook/cache-do
## 0.1.1
### Patch Changes
- b7a5106: Disable cloudflare observability in production
## 0.1.0
### Minor Changes
+1 -1
View File
@@ -14,7 +14,7 @@
"default": "./dist/api.js"
}
},
"version": "0.1.1",
"version": "0.1.0",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lru_map": "^0.4.1"
-8
View File
@@ -1,8 +0,0 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
-7
View File
@@ -1,7 +0,0 @@
# gitbook-v2
## 0.1.0
### Minor Changes
- cfccc44: Setup structure and deployment for new version
-104
View File
@@ -1,104 +0,0 @@
# GitBook V2
This package contains the code for an experiment new version of the GitBook renderer.
The goals of this new version are:
- Optimize rendering to leverage Next.js ISR and static rendering
- Leverage Node.js runtime and bigger instances to render bigger content
- Evaluate Vercel and Cloudflare deployment options
## Deployment
#### Vercel
The new version is deployed automatically to Vercel using a Github Action on every push.
#### Cloudflare with Opennext-js
The new version is deployed automatically to Cloudflare using a Github Action on every push.
## Potential issues
- [ ] We can't use `dynamicIO` because our root layout needs to fetch the params (https://github.com/vercel/next.js/discussions/71927)
- [ ] How to do `nonce` for static resources when we can't use `headers()` (dynamic API) (https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy)
## Structure
#### Middleware:
Goals:
- Route between dynamic and static routes
- If a token is in the cookies => dynamic route
- If a token is in the url => dynamic route
- Else => static route
- High performances:
- It should not rely on data fetching
#### URLs to support:
Content URLs:
- `docs.company.com/page`
- Middleware: rewrite to `/static/url/docs.company.com/page`
- Page component renders the page
- `docs.company2.com/section/page`
- Middleware: rewrite to `/static/url/docs.company2.com/section/page`
- Page component renders the page
- `docs.company3.com/section/sitespace/page`
- Middleware: rewrite to `/static/url/docs.company3.com/section/sitespace/page`
- Page component renders the page
Content URLs (adaptive content or visitor authentication):
- `docs.company.com/page`
- Middleware:
- If a token is in the cookies or url => dynamic route
- Else => rewrite to `/dynamic/url/docs.company.com/page`
- Page component renders the page
- Read the token from the url query params or cookies
PDF export URLs:
- `docs.company.com/page/~gitbook/pdf`
- Middleware: rewrite to `/dynamic/pdf/docs.company.com/page`
- Page component renders the page
Images resizing:
- `docs.company.com/~gitbook/image`
- Middleware: rewrite to `/dynamic/image/docs.company.com`
- Route handlers process the request
Preview URLs:
- `open.gitbook.com/sites/site_abc/page` (or `open.gitbook.com/~site/site_abc/page` to stay compatible?)
- Middleware: no rewrite
- Page component renders the page (dynamic?)
- Read the token from the url query params
- Read customizations override from the url query params
Space PDF URLs:
- `open.gitbook.com/spaces/space_abc/pdf`
- Middleware: no rewrite
- Page component renders the page (dynamic?)
- Read the token from the url query params
- Read customizations override from the url query params
Development:
- `localhost:3000/sites/site_abc/page`
#### Routes:
- Content
- [ ] `/static/url/[[...url]]/page.tsx`
- [ ] `/dynamic/url/[[...url]]/page.tsx`
- Images resizing
- [ ] `/dynamic/image/[hostname]/route.ts`
- PDF export
- [ ] `/dynamic/pdf/[[...url]]/page.tsx`
- [ ] `/spaces/[space]/pdf/page.tsx`
-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.
-15
View File
@@ -1,15 +0,0 @@
// @ts-check
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
useCache: true,
// We can't use dynamicIO because it doesn't accept reading params in the root layout
// dynamicIO: true,
},
};
export default nextConfig;
-30
View File
@@ -1,30 +0,0 @@
// default open-next.config.ts file created by @opennextjs/cloudflare
import cache from '@opennextjs/cloudflare/kvCache';
const config = {
default: {
override: {
wrapper: 'cloudflare-node',
converter: 'edge',
incrementalCache: async () => cache,
tagCache: 'dummy',
queue: 'dummy',
},
},
middleware: {
external: true,
override: {
wrapper: 'cloudflare-edge',
converter: 'edge',
proxyExternalRequest: 'fetch',
},
},
dangerous: {
enableCacheInterception: false,
},
};
export default config;
-22
View File
@@ -1,22 +0,0 @@
{
"name": "gitbook-v2",
"version": "0.1.0",
"private": true,
"dependencies": {
"next": "canary",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@gitbook/api": "^0.93.0"
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.4.3"
},
"scripts": {
"dev:v2": "next",
"build": "next build",
"build:v2": "next build",
"start": "next start",
"build:v2:cloudflare": "opennextjs-cloudflare",
"dev:v2:cloudflare": "wrangler dev --port 8771"
}
}
@@ -1,15 +0,0 @@
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
import { createDynamicSiteContext } from '@/lib/context';
export default async function RootLayout({
params,
children,
}: {
params: Promise<{ url: string[] }>;
children: React.ReactNode;
}) {
const { url } = await params;
const context = await createDynamicSiteContext(url);
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
}
@@ -1,9 +0,0 @@
import { SiteContentPage } from '@/components/routes/SiteContentPage';
import { createDynamicSiteContext } from '@/lib/context';
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
const { url } = await params;
const context = await createDynamicSiteContext(url);
return <SiteContentPage context={context} />;
}
@@ -1,21 +0,0 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
import { getSiteCacheTag } from '@/lib/cache';
import { createStaticSiteContext } from '@/lib/context';
export default async function RootLayout({
params,
children,
}: {
params: Promise<{ url: string[] }>;
children: React.ReactNode;
}) {
'use cache';
const { url } = await params;
const context = await createStaticSiteContext(url);
cacheTag(getSiteCacheTag(context.siteId));
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
}
@@ -1,15 +0,0 @@
import { unstable_cacheTag as cacheTag } from 'next/cache';
import { createStaticSiteContext } from '@/lib/context';
import { getSiteCacheTag } from '@/lib/cache';
import { SiteContentPage } from '@/components/routes/SiteContentPage';
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
'use cache';
const { url } = await params;
const context = await createStaticSiteContext(url);
cacheTag(getSiteCacheTag(context.siteId));
return <SiteContentPage context={context} />;
}
@@ -1,27 +0,0 @@
import { GitBookSiteContext } from '@/lib/context';
/**
* Layout component to render the site content.
*/
export async function SiteContentLayout({
context,
children,
}: {
context: GitBookSiteContext;
children: React.ReactNode;
}) {
const { api } = context;
const { data: publishedSite } = await api.orgs.getPublishedContentSite(
context.organizationId,
context.siteId,
);
return (
<html lang="en">
<body>
<h1>{publishedSite.site.title}</h1>
{children}
</body>
</html>
);
}
@@ -1,12 +0,0 @@
import { GitBookSiteContext } from '@/lib/context';
export async function SiteContentPage({ context }: { context: GitBookSiteContext }) {
const { api } = context;
const { data } = await api.orgs.getPublishedContentSite(context.organizationId, context.siteId);
return (
<div>
<p>Found site {data.site.id}</p>
</div>
);
}
-3
View File
@@ -1,3 +0,0 @@
export function getSiteCacheTag(siteId: string) {
return `sites/${siteId}`;
}
-112
View File
@@ -1,112 +0,0 @@
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { GitBookAPI } from '@gitbook/api';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@/lib/env';
/**
* Generic context about rendering content.
*/
export interface GitBookContext {
/**
* API client to fetch data from GitBook.
*/
api: GitBookAPI;
}
/**
* Context when rendering a site.
*/
export interface GitBookSiteContext extends GitBookContext {
/**
* ID of the organization.
*/
organizationId: string;
/**
* Site ID.
*/
siteId: string;
}
/**
* Create a site context, when rendering a static page.
*/
export async function createStaticSiteContext(url: string[]): Promise<GitBookSiteContext> {
const context = createStaticContext();
return fetchSiteContext(url, context);
}
/**
* Create a site context, when rendering a dynamic page.
*/
export async function createDynamicSiteContext(url: string[]): Promise<GitBookSiteContext> {
const context = await createDynamicContext();
return fetchSiteContext(url, context);
}
/**
* Fetch the context of a site for a given URL and a base context.
*/
async function fetchSiteContext(
urlParts: string[],
baseContext: GitBookContext,
): Promise<GitBookSiteContext> {
const { api } = baseContext;
const url = getURLFromParams(urlParts);
const { data } = await api.urls.getPublishedContentByUrl({
url,
});
if ('redirect' in data) {
redirect(data.redirect);
}
return {
...baseContext,
api: new GitBookAPI({
endpoint: api.endpoint,
authToken: data.apiToken,
userAgent: api.userAgent,
}),
siteId: data.site,
organizationId: data.organization,
};
}
/**
* Create the base context when rendering statically.
*/
function createStaticContext(): GitBookContext {
const api = new GitBookAPI({
authToken: GITBOOK_API_TOKEN,
endpoint: GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
});
return {
api,
};
}
/**
* Create the base context when rendering dynamically.
* The context will depend on the request.
*/
async function createDynamicContext(): Promise<GitBookContext> {
const headersSet = await headers();
const api = new GitBookAPI({
authToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
endpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
userAgent: GITBOOK_USER_AGENT,
});
return {
api,
};
}
function getURLFromParams(input: string[]) {
const url = new URL('https://' + input.join('/'));
return url.toString();
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Default GitBook API URL endpoint.
*/
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
/**
* Default GitBook API token.
* It can be use to avoid rate-limiting.
*/
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
/**
* User agent to use for API requests.
*/
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
-11
View File
@@ -1,11 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
};
export function middleware(request: NextRequest) {
// No rewrite
return NextResponse.next();
}
-28
View File
@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@v1/*": ["../gitbook/src/*"]
}
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
-5
View File
@@ -1,5 +0,0 @@
main = ".open-next/worker.js"
name = "gitbook-v2"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
assets = { directory = ".open-next/assets", binding = "ASSETS" }
-210
View File
@@ -1,215 +1,5 @@
# gitbook
## 0.6.1
### Patch Changes
- dddb4ec: Fix long tab group description
- Updated dependencies [f8d4c76]
- Updated dependencies [dddb4ec]
- Updated dependencies [f8d4c76]
- @gitbook/react-openapi@1.0.1
## 0.6.0
### Minor Changes
- 98245e5: Adapt code to pull token from customer backend generated custom cookies
- af3c6a9: Reintroduce a safety check around search whilst we continue investigating caching.
- 95f2aa4: Track new events for site insights when ads are being clicked
- 08acea6: Investigate an issue causing caches to return empty objects instead of null/undefined.
- 1138d59: Add support for sidebar background styles
- 9e18ae6: Overhaul colour scale & Tailwind colour classes
- e86e51f: Fix an issue where the redirects of potentially malicious images were not going through.
- 7059c2b: Add support for computed content by fetching computed documents for pages.
- c71d159: Track events for site insights using the new dedicated API.
- eb7c22f: Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
- ea1468c: Send redirectOnError param to getPublishedContent when token is pulled from cookie
- 7ee9158: Restyle PageAside to use sidebar list styles
- dbba50c: Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
- 1417279: Track clicks on links (header, footer, content) for site insights.
- 9eca010: Improve the display of recommended questions by streaming them.
- 160fca1: new OpenAPI blocks design
- 71688a8: Introduction of new themes: Clean, Muted, Bold, and Gradient
- 1823101: Fix internal properties appearing in OpenAPI docs.
- 6a073e1: Add antialiasing for text rendering
- 8126a83: Improve readability of tables with hover style and vertical dividers
- db74ea3: Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
- 99579ac: Fix a vulnerability issue for images using an older version of the image signing parameter.
- e4e2f52: Track an event into site insights when visitor is opening the Scalar API client.
### Patch Changes
- d876399: Fix UI search without ask AI enabled and fix error with questions not returned from API
- c30bc24: Fix empty sitemap
- e90c96f: page outline on the right remains visible when scrolling, move mode toggler to PageAside
- 5b4e710: Support llms.txt
- b6c3870: Add support for keyboard marks
- 6059efe: Fix search no results error showing while there are results
- c77142a: Log component stack in Sentry
- 1de9d1a: Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
- 32aa1f9: Handle security issue with cookies on Safari
- d935fb1: Don't add extra page scroll when footer is not present
- 53de5b1: Fix site section URL resolution in Ask AI sources
- 24f5249: Fix vertical section overflow color
- 1762f85: Reduce gap between subsequent header buttons
- c1e27cc: Fix pass Sentry release properly
- 5ae1b88: Fix shrinking page icons
- 8f046a9: Start using tint in more places, TOC and PageAside
- 665b6be: Ignore invalid API calls to `getSiteRedirectFromSource` API
To reduce the load on the API and also avoid errors.
- 26e6401: Remove KV cache backend and only rely on DO as an external cache backend
- 8cfa67c: Fix default outline list styling
- d66c184: Ignore errors from event flushing
- 6088fa5: Simplify search results logic to investigate a bug
- 68287d3: Cache API spec for 24 hours, revalidated every 2 hours
- 09c7c30: Try to fix error on og image generation
- ae99f87: Improve emoji setup, align with GitBook app
- 2906e60: Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
- 3a7210d: Fix zoom image view transition on Safari
- 718a8a5: Position the variant picker in the ToC
- e5dc05e: Update footer styling and allow for more than 4 footer groups
- 8276ba0: Make cookies access safer
- 1b8a456: Fix Image blocks zoomable behaviour
- 56c52e0: Handle Firefox security error on localStorage
- 0510b6f: Add section description to SectionGroupTile
- 1fcc807: Fix errors from customization not found
- 46edde9: Improve the OpenAPI package API
- 8af1abc: Improve contrast of search box placeholder
- 92b7668: Improve header offset
- d9c8d57: Do not dereference before caching OpenAPI spec.
- 94876e3: Fix regression issue with page icons for multi-line titles
- 47971dc: Fix OG image generation for non-latin characters
- 82dc9c4: Simplify the `useHash` algo used.
- 128ad20: Ignore cache invalidation error from local backend
- ff05e20: Improvements to inline images in headings
- cb100d5: Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
- d5aaccd: Remove use of deprecatd API createSitesPageFeedback
- 48ab59f: Improve colour contrast of list item decoration
- d2bc567: Set Sentry release
- 37d13d8: Avoid error on fetch by passing a string URL
- d3e573c: Generate sitemap for all sections and spaces
- f7b801b: Add feedback form to page rating control
- d370a3f: Update the routes for changes/revisions in multi-id mode to match the normal mode
- 46f63cb: Fix code format overriding inline link styles
- 5950657: Fix emojis display
- 528a053: Fix server actions stability leading to no results found sometimes on search
- eac1314: Lazy load iframely script to make page more responsive
- ad19060: Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
- 6f54826: New highlight colors
- 5c87ec7: Implement a safer way to interact with localStorage.
If it's disabled on the browser it should not throw error.
- 02d876e: Fix search UI behaviour
- f4a90de: Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
- 5576906: Fix table of content displaying arrow next to page with only hidden pages
- aaab157: Visual fix for section group in Safari
- cbe6139: Fix dynamic tabs infinite loop
- 65cc4af: Fix error when accessing a change request not found
- 727bde2: Improve and split OpenAPI parser into its own package
- 0b6ddca: Fix variant selector contrast for non-default themes
- 87b8ea8: Fix issue leading to increase the storage write and the stability of the platform
- fde32e2: Force route handler to be dynamic to avoid errors
- a025118: Change card layout depending on cover aspect ratio
- 300f7bf: Fix search loading state
- 29d5979: Disable C/C++ highlight temporarily
- 18953b2: Subtler tint color when based on the primary color, by mixing in some gray
- 1c97536: Fix Sentry instrumentation
- b0bd871: Even safer localStorage
- b950a64: Avoid errors on legacy browsers
- 38061bd: Add section groups to section tabs
- 160fca1: Support deprecated and x-deprecated-sunset in OpenAPI spec
- 0e601e2: Improve styling of header buttons with shadows and high-contrast styles
- 6691492: Fix viewing PDF from space
- e8e64bf: Fix bullet list display on full size blocks
- 16194c5: Vertical orientation for sections list on sites without header
- b41d425: Improve OpenAPI rendering performances by caching markdown parsing
- 1f8e416: Improve performances by highlighting code client-side if the code block is offscreen
- 1429384: Fix error when accessing some not found pages.
- 21cbd9e: Change link color to primary-subtle
- 5dab70f: Fix "Parser" language syntax highlighting
- deb8c54: Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
- 56331d2: Fix breadcrumbs emoji display + add contrast styles
- a6f6591: Fix server actions cache compromised. Leading to some bugs on frontend.
- 44a20fe: Improve smoothness of scroll listener
- 5664e5a: Fix variant dropdown styling in header
- 6b50360: Fix view transition error on Safari
- 741dd49: Bump `heading-3` font size to offset it from paragraphs
- 5112e3e: Fix Sentry instrumentation server-side
- 1de338c: Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
- Updated dependencies [d9029c7]
- Updated dependencies [6e54a06]
- Updated dependencies [162b4b7]
- Updated dependencies [e4e2f52]
- Updated dependencies [0c03676]
- Updated dependencies [3e5e458]
- Updated dependencies [46edde9]
- Updated dependencies [d9c8d57]
- Updated dependencies [ccf2cff]
- Updated dependencies [dda0cc6]
- Updated dependencies [eb7c22f]
- Updated dependencies [ea1468c]
- Updated dependencies [648f0e9]
- Updated dependencies [160fca1]
- Updated dependencies [f92e906]
- Updated dependencies [e721f17]
- Updated dependencies [727bde2]
- Updated dependencies [dff08ae]
- Updated dependencies [fc7b16f]
- Updated dependencies [fe8acc9]
- Updated dependencies [1823101]
- Updated dependencies [a652958]
- Updated dependencies [2f73db7]
- Updated dependencies [160fca1]
- Updated dependencies [12c7862]
- Updated dependencies [b41d425]
- @gitbook/react-openapi@1.0.0
- @gitbook/openapi-parser@1.0.0
- @gitbook/react-contentkit@0.6.0
## 0.5.0
### Minor Changes
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
- a2e5647: Fix the styling of site section tabs on smaller screens.
### Patch Changes
- 076dc48: Fix expandable block anchore resolution
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
- 664debc: Add support for tint color
- 4d56f11: Update styling of search+ask modal
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
- 2f76712: Add breadcrumbs above page title
- 07cf835: Add scroll margin to the top when there are sections
- 5d72b35: Smoother tab transition for sections
- 7c71363: Don't adjust fallback font for mono font.
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
- 87eea73: Fix margin and image resolution of header logo
- aa2ed0f: Restyle hint blocks
- ffd3937: Fix security issue with image resizing that could be used for phishing
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
- c73e07d: Increase token max length to fix code not highlighted
- 3b3d6e2: Add icons to sections
- 1ed18c0: style: adds missing scalar css variables
- Updated dependencies [b7a5106]
- Updated dependencies [4771c78]
- Updated dependencies [ff50ac2]
- Updated dependencies [867481c]
- Updated dependencies [7ba67fd]
- Updated dependencies [a78c1ec]
- @gitbook/cache-do@0.1.1
- @gitbook/react-openapi@0.7.1
## 0.4.0
### Minor Changes
-240
View File
@@ -1,240 +0,0 @@
import { runTestCases, TestsCase, waitForCookiesDialog } from './util';
/** A list of test cases to run on the customers' docs sites. */
const testCases: TestsCase[] = [
{
name: 'Snyk',
baseUrl: 'https://docs.snyk.io',
tests: [
{ name: 'Home', url: '/' },
{ name: 'OpenAPI', url: '/snyk-api/reference/apps' },
],
},
{
name: 'Nexthink',
baseUrl: 'https://docs.nexthink.com',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'asiksupport-stg.dto.kemkes.go.id',
baseUrl: 'https://asiksupport-stg.dto.kemkes.go.id',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'jasons-tutorials.gitbook.io',
baseUrl: 'https://jasons-tutorials.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'faq.deltaemulator.com',
baseUrl: 'https://faq.deltaemulator.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dify.ai',
baseUrl: 'https://docs.dify.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'seeddao.gitbook.io',
baseUrl: 'https://seeddao.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'faq.altstore.io',
baseUrl: 'https://faq.altstore.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'support.audacityteam.org',
baseUrl: 'https://support.audacityteam.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.gmgn.ai',
baseUrl: 'https://docs.gmgn.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.spicychat.ai',
baseUrl: 'https://docs.spicychat.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.portainer.io',
baseUrl: 'https://docs.portainer.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.chirptoken.io',
baseUrl: 'https://docs.chirptoken.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.dexscreener.com',
baseUrl: 'https://docs.dexscreener.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.pancakeswap.finance',
baseUrl: 'https://docs.pancakeswap.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'book.character.ai',
baseUrl: 'https://book.character.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.tradeonnova.io',
baseUrl: 'https://docs.tradeonnova.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'azcoiner.gitbook.io',
baseUrl: 'https://azcoiner.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.midas.app',
baseUrl: 'https://docs.midas.app',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.keeper.io',
baseUrl: 'https://docs.keeper.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'adiblar.gitbook.io',
baseUrl: 'https://adiblar.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.gradient.network',
baseUrl: 'https://docs.gradient.network',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'mygate-network.gitbook.io',
baseUrl: 'https://mygate-network.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'treasurenft.gitbook.io',
baseUrl: 'https://treasurenft.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'browndust2.gitbook.io',
baseUrl: 'https://browndust2.gitbook.io',
tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'junookyo.gitbook.io',
baseUrl: 'https://junookyo.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'meshnet.nordvpn.com',
baseUrl: 'https://meshnet.nordvpn.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'manual.bubble.io',
baseUrl: 'https://manual.bubble.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.tickettool.xyz',
baseUrl: 'https://docs.tickettool.xyz',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'wiki.redmodding.org',
baseUrl: 'https://wiki.redmodding.org',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.cherry-ai.com',
baseUrl: 'https://docs.cherry-ai.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.snyk.io',
baseUrl: 'https://docs.snyk.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.realapp.link',
baseUrl: 'https://docs.realapp.link',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.plaza.finance',
baseUrl: 'https://docs.plaza.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.publicai.io',
baseUrl: 'https://docs.publicai.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'hyperliquid.gitbook.io',
baseUrl: 'https://hyperliquid.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.umbraco.com',
baseUrl: 'https://docs.umbraco.com',
tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }],
},
{
name: 'sosovalue-white-paper.gitbook.io',
baseUrl: 'https://sosovalue-white-paper.gitbook.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.revrobotics.com',
baseUrl: 'https://docs.revrobotics.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'chartschool.stockcharts.com',
baseUrl: 'https://chartschool.stockcharts.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.soniclabs.com',
baseUrl: 'https://docs.soniclabs.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.meshchain.ai',
baseUrl: 'https://docs.meshchain.ai',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.thousandeyes.com',
baseUrl: 'https://docs.thousandeyes.com',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.raydium.io',
baseUrl: 'https://docs.raydium.io',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.solv.finance',
baseUrl: 'https://docs.solv.finance',
tests: [{ name: 'Home', url: '/' }],
},
{
name: 'docs.fluentbit.io',
baseUrl: 'https://docs.fluentbit.io',
tests: [{ name: 'Home', url: '/' }],
},
];
runTestCases(testCases);
@@ -1,32 +1,46 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationSidebarListStyle,
CustomizationLocale,
SiteCustomizationSettings,
} from '@gitbook/api';
import { expect } from '@playwright/test';
import { test, expect, Page } from '@playwright/test';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import {
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
VISITOR_TOKEN_COOKIE,
} from '@/lib/visitor-token';
import { getContentTestURL } from '../tests/utils';
import {
allDeprecatedThemePresets,
allLocales,
allSidebarBackgroundStyles,
allThemeModes,
allThemes,
allTintColors,
getCustomizationURL,
headerLinks,
runTestCases,
TestsCase,
waitForCookiesDialog,
} from './util';
interface Test {
name: string;
url: string; // URL to visit for testing
run?: (page: Page) => Promise<unknown>; // The test to run
fullPage?: boolean; // Whether the test should be fullscreened during testing
screenshot?: false; // Should a screenshot be stored
only?: boolean; // Only run this test
}
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[] = [
{
@@ -50,28 +64,19 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
@@ -90,9 +95,7 @@ const testCases: TestsCase[] = [
name: 'Variants dropdown',
url: '',
run: async (page) => {
const spaceDrowpdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
await spaceDrowpdown.waitFor();
},
},
@@ -108,9 +111,7 @@ const testCases: TestsCase[] = [
name: 'Customized variant titles are displayed',
url: '',
run: async (page) => {
const spaceDrowpdown = page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
await spaceDrowpdown.click();
const variantSelectionDropdown = page.locator(
@@ -142,9 +143,9 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
await spaceDrowpdown.click();
// Click the second variant in the dropdown
@@ -165,9 +166,9 @@ const testCases: TestsCase[] = [
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
screenshot: false,
run: async (page) => {
const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
await spaceDrowpdown.click();
// Click the second variant in the dropdown
@@ -200,9 +201,9 @@ const testCases: TestsCase[] = [
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
})(),
run: async (page) => {
const spaceDrowpdown = await page
.locator('[data-testid="space-dropdown-button"]')
.locator('visible=true');
const spaceDrowpdown = await page.waitForSelector(
'[data-testid="space-dropdown-button"]',
);
await spaceDrowpdown.click();
// Click the second variant in the dropdown
@@ -232,28 +233,19 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
@@ -282,9 +274,6 @@ const testCases: TestsCase[] = [
{
name: 'PDF',
url: '~gitbook/pdf?limit=10',
screenshot: {
waitForTOCScrolling: false,
},
},
],
},
@@ -307,38 +296,11 @@ const testCases: TestsCase[] = [
url: 'blocks/block-images',
run: waitForCookiesDialog,
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Images (with zoom)',
url: 'blocks/block-images',
run: async (page) => {
await waitForCookiesDialog(page);
const zoomImage = page.getByTestId('zoom-image');
await zoomImage.first().click();
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
},
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Inline Images',
url: 'blocks/inline-images',
run: async (page) => {
await waitForCookiesDialog(page);
// Make the text invisible to fix flakiness due to the text position.
await page.evaluate(() => {
for (const p of document.querySelectorAll('p')) {
if (
p.textContent?.includes(
'This image has intrinsic 400px width, but renders as 300px:',
)
) {
p.style.color = 'transparent';
}
}
});
},
run: waitForCookiesDialog,
},
{
name: 'Tabs',
@@ -409,18 +371,6 @@ const testCases: TestsCase[] = [
{
name: 'Math',
url: 'blocks/math',
run: async (page) => {
await page.waitForFunction(() => {
const fonts = Array.from(document.fonts.values());
const mjxFonts = fonts.filter(
(font) => font.family === 'MJXZERO' || font.family === 'MJXTEX',
);
return (
mjxFonts.length === 2 &&
mjxFonts.every((font) => font.status === 'loaded')
);
});
},
},
{
name: 'Files',
@@ -474,9 +424,6 @@ const testCases: TestsCase[] = [
name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
},
{
name: 'With icon',
@@ -488,138 +435,50 @@ const testCases: TestsCase[] = [
{
name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: allThemeModes.flatMap((themeMode) => [
tests: [
{
name: `Without header - Theme mode ${themeMode}`,
name: 'Without header',
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.None,
links: [],
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: `With duotone icons - Theme mode ${themeMode}`,
name: 'With duotone icons',
url:
'page-options/page-with-icon' +
getCustomizationURL({
styling: {
icons: CustomizationIconsStyle.Duotone,
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
{
name: `With header buttons - Theme mode ${themeMode}`,
name: 'With header buttons',
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.Default,
links: headerLinks,
},
themes: {
default: themeMode,
toggeable: false,
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
},
}),
run: waitForCookiesDialog,
},
{
name: `Without tint - Default preset - Theme mode ${themeMode}`,
url: getCustomizationURL({
header: {
preset: CustomizationHeaderPreset.Default,
links: headerLinks,
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
// New site themes
...allThemes.flatMap((theme) => [
...allTintColors.flatMap((tint) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`,
url: getCustomizationURL({
styling: {
theme,
...(tint.value ? { tint: { color: tint.value } } : {}),
sidebar: {
background: sidebarStyle,
list: CustomizationSidebarListStyle.Default,
},
},
header: {
links: headerLinks,
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
]),
]),
// Deprecated header themes
...allDeprecatedThemePresets.flatMap((preset) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `With tint - Legacy header preset ${preset} - Sidebar ${sidebarStyle} - Theme mode ${themeMode}`,
url: getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
sidebar: {
background: sidebarStyle,
list: CustomizationSidebarListStyle.Default,
},
},
header: {
preset,
...(preset === CustomizationHeaderPreset.Custom
? {
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
linkColor: { light: '#4DDE98', dark: '#0C693D' },
}
: {}),
links: headerLinks,
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
]),
{
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
url: getCustomizationURL({
styling: {
background: CustomizationBackground.Match,
},
header: {
preset: CustomizationHeaderPreset.Default,
links: headerLinks,
},
themes: {
default: themeMode,
toggeable: false,
},
}),
run: waitForCookiesDialog,
},
]),
],
},
{
name: 'Ads',
@@ -642,14 +501,11 @@ const testCases: TestsCase[] = [
run: async (page) => {
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
expect(page.locator('h1')).toHaveText('shared');
const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
],
},
@@ -661,15 +517,13 @@ const testCases: TestsCase[] = [
name: 'Navigation to shared space',
url: '',
run: async (page) => {
await page.locator('a.underline').click();
await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared');
const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
],
},
@@ -683,7 +537,6 @@ const testCases: TestsCase[] = [
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO');
},
screenshot: false,
},
],
},
@@ -703,7 +556,6 @@ const testCases: TestsCase[] = [
page.getByText('Authentication missing to access this content'),
).toBeVisible();
},
screenshot: false,
},
],
},
@@ -726,12 +578,7 @@ const testCases: TestsCase[] = [
);
return `first?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
run: waitForCookiesDialog,
},
{
name: 'Second',
@@ -748,12 +595,7 @@ const testCases: TestsCase[] = [
);
return `second?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'second' }),
).toBeVisible();
},
screenshot: false,
run: waitForCookiesDialog,
},
],
},
@@ -867,12 +709,7 @@ const testCases: TestsCase[] = [
);
return `first?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
run: waitForCookiesDialog,
},
{
name: 'Custom page',
@@ -910,60 +747,6 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/`,
tests: [
{
name: 'Redirect to fallback on invalid token pulled from cookie',
url: '',
screenshot: false,
cookies: (() => {
const basePath = '/va-site-redirects-fallback/';
const invalidToken = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
},
);
return [
{
name: getVisitorAuthCookieName(basePath),
value: getVisitorAuthCookieValue(basePath, invalidToken),
httpOnly: true,
},
];
})(),
run: async (page) => {
await expect(page).toHaveURL(/https:\/\/www.google.com/);
},
},
{
name: 'Show error message when invalid token is passed to url',
screenshot: false,
url: (() => {
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(page.locator('pre')).toContainText(
'Error while validating the JWT token. Reason: The token signature is invalid.',
);
},
},
],
},
{
name: 'Languages',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1031,321 +814,44 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Adaptive Content - VA',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-va/`,
tests: [
{
name: 'isAlphaUser',
url: (() => {
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha users' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta users' });
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'isBetaUser',
url: (() => {
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha users' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta users' });
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
},
},
{
name: 'isAlphaUser & isBetaUser',
url: (() => {
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha users' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta users' });
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toBeVisible();
},
},
],
},
{
name: 'Adaptive Content - Public',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/`,
tests: [
{
name: 'No custom cookie',
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isAlphaUser claim',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isBetaUser claim',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isAlphaUser & isBetaUser claims',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toBeVisible();
},
},
],
},
{
name: 'Tables',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: [
{
name: 'Default table',
url: 'blocks/tables',
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Table with straight corners',
url:
'blocks/tables' +
getCustomizationURL({
styling: {
corners: CustomizationCorners.Straight,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
{
name: 'Table with primary color',
url:
'blocks/tables' +
getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
// Test dark mode for each variant
...allThemeModes.flatMap((theme) => [
{
name: `Table in ${theme} mode`,
url:
'blocks/tables' +
getCustomizationURL({
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
{
name: `Table with straight corners in ${theme} mode`,
url:
'blocks/tables' +
getCustomizationURL({
styling: {
corners: CustomizationCorners.Straight,
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
{
name: `Table with primary color in ${theme} mode`,
url:
'blocks/tables' +
getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
fullPage: true,
},
]),
],
},
];
runTestCases(testCases);
for (const testCase of testCases) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const testFn = testEntry.only ? test.only : test;
testFn(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: DeepPartial<SiteCustomizationSettings>): string {
const encoded = rison.encode_object(partial);
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
return `?${searchParams.toString()}`;
}
-332
View File
@@ -1,332 +0,0 @@
import { argosScreenshot } from '@argos-ci/playwright';
import {
CustomizationBackground,
CustomizationCorners,
CustomizationFont,
CustomizationHeaderItem,
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationTheme,
CustomizationThemedColor,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { test, expect, Page, BrowserContext } from '@playwright/test';
import deepMerge from 'deepmerge';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import { getContentTestURL } from '../tests/utils';
export interface Test {
name: string;
/**
* URL to visit for testing.
*/
url: string;
cookies?: Parameters<BrowserContext['addCookies']>[0];
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
fullPage?: boolean;
/**
* Whether to take a screenshot of the test or set a threshold for the screenshot.
*/
screenshot?:
| false
| {
/**
* Screenshot threshold.
* From 0 to 1, where 0 is the most strict and 1 is the most permissive.
* @default 0.5
*/
threshold?: number;
/**
* Whether to wait for the table of contents to finish scrolling before taking the screenshot.
*/
waitForTOCScrolling?: boolean;
};
/**
* Whether to only run this test.
*/
only?: boolean;
}
export interface TestsCase {
name: string;
baseUrl: string;
tests: Array<Test>;
}
export const allLocales: CustomizationLocale[] = [
CustomizationLocale.Fr,
CustomizationLocale.Es,
CustomizationLocale.Ja,
CustomizationLocale.Zh,
];
export const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
export const allTintColors: Array<{
label: string;
value: CustomizationThemedColor | undefined;
}> = [
{
label: 'Off',
value: undefined,
},
{ label: 'Primary', value: { light: '#346DDB', dark: '#346DDB' } },
{ label: 'Custom', value: { light: '#C62C68', dark: '#EF96B8' } },
];
export const allThemes: CustomizationTheme[] = [
CustomizationTheme.Clean,
CustomizationTheme.Muted,
CustomizationTheme.Bold,
CustomizationTheme.Gradient,
];
export const allDeprecatedThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Default,
CustomizationHeaderPreset.Bold,
CustomizationHeaderPreset.Contrast,
CustomizationHeaderPreset.Custom,
];
export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
CustomizationSidebarBackgroundStyle.Default,
CustomizationSidebarBackgroundStyle.Filled,
];
// Common customization settings
export const headerLinks: CustomizationHeaderItem[] = [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
links: [],
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
links: [],
},
];
export 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();
}
/**
* Transform test cases into Playwright tests and run it.
*/
export function runTestCases(testCases: TestsCase[]) {
for (const testCase of testCases) {
test.describe(testCase.name, () => {
for (const testEntry of testCase.tests) {
const testFn = testEntry.only ? test.only : test;
testFn(testEntry.name, async ({ page, baseURL, context }) => {
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
const url = getContentTestURL(contentUrl.toString(), baseURL);
if (testEntry.cookies) {
await context.addCookies(
testEntry.cookies.map((cookie) => ({
...cookie,
domain: new URL(url).host,
path: '/',
})),
);
}
await page.goto(url);
if (testEntry.run) {
await testEntry.run(page);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async () => {
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
}
});
}
});
}
}
/**
* Create a URL with customization settings.
*/
export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
// We replicate the theme migration logic from the API to the tests, because the don't get these settings from the API.
// We can remove this once the migration to the new themes have been completed and the new theme styles are verified
// Map the theme preset (+ tint) to one of the new themes
const newTheme = (() => {
if (partial.styling?.theme) {
return partial.styling.theme;
}
switch (partial.header?.preset) {
case CustomizationHeaderPreset.Bold:
case CustomizationHeaderPreset.Contrast:
case CustomizationHeaderPreset.Custom:
return CustomizationTheme.Bold;
case CustomizationHeaderPreset.None:
case CustomizationHeaderPreset.Default:
if (partial.styling?.tint) {
return CustomizationTheme.Muted;
}
return CustomizationTheme.Clean;
default:
return CustomizationTheme.Clean;
}
})();
/**
* Default customization settings.
*
* The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults.
*/
const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = {
styling: {
theme: newTheme,
primaryColor: { light: '#346DDB', dark: '#346DDB' },
corners: CustomizationCorners.Rounded,
font: CustomizationFont.Inter,
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
},
},
internationalization: {
locale: CustomizationLocale.En,
},
favicon: {},
header: {
preset: CustomizationHeaderPreset.Default,
links: [],
},
footer: {
groups: [],
},
themes: {
default: CustomizationThemeMode.Light,
toggeable: true,
},
pdf: {
enabled: true,
},
feedback: {
enabled: false,
},
aiSearch: {
enabled: true,
},
advancedCustomization: {
enabled: true,
},
git: {
showEditLink: false,
},
pagination: {
enabled: true,
},
trademark: {
enabled: true,
},
privacyPolicy: {
url: 'https://www.gitbook.com/privacy',
},
socialPreview: {},
};
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
const searchParams = new URLSearchParams();
searchParams.set('customization', encoded);
return `?${searchParams.toString()}`;
}
/**
* Wait for all icons present on the page to be loaded.
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(async () => {
function loadImage(src: string) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (error) => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
await Promise.all(
icons.map(async (icon) => {
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
throw new Error('No mask-image');
}
await loadImage(url);
}),
);
});
}
/**
* Wait for TOC to be correctly scrolled into view.
*/
async function waitForTOCScrolling(page: Page) {
const viewport = await page.viewportSize();
if (viewport && viewport.width >= 1024) {
const toc = page.getByTestId('table-of-contents');
await expect(toc).toBeVisible();
await page.evaluate(() => {
const tocScrollContainer = document.querySelector(
'[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]',
);
if (!tocScrollContainer) {
throw new Error('TOC scroll container not found');
}
tocScrollContainer.scrollTo(0, 0);
});
}
}
+7 -5
View File
@@ -4,13 +4,11 @@ module.exports = withSentryConfig(
{
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
},
webpack(config, { dev, webpack }) {
@@ -69,15 +67,19 @@ module.exports = withSentryConfig(
},
},
{
release: process.env.SENTRY_RELEASE,
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
},
{
// 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,
},
);
+23 -29
View File
@@ -1,6 +1,6 @@
{
"name": "gitbook",
"version": "0.6.1",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -9,29 +9,26 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"e2e": "playwright test e2e/internal.spec.ts",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"e2e": "playwright test",
"unit": "bun test {src,packages}",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static/icons && rm -rf ./public/~gitbook/static/math"
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.93.0",
"@gitbook/api": "^0.80.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "8.35.0",
"@sentry/nextjs": "^7.94.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1",
"ajv": "^8.12.0",
"assert-never": "^1.2.1",
@@ -43,18 +40,19 @@
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"next": "14.2.23",
"next": "14.2.15",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"nuqs": "^1.17.4",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"postcss-color-contrast": "^1.1.0",
"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",
@@ -62,33 +60,28 @@
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.27.2",
"shiki": "^1.23.1",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
"url-join": "^5.0.0"
},
"devDependencies": {
"@argos-ci/playwright": "^3.10.0",
"@cloudflare/next-on-pages": "1.13.7",
"vercel": "^39.3.0",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@argos-ci/playwright": "^2.0.0",
"@playwright/test": "^1.42.1",
"@cloudflare/next-on-pages": "^1.13.5",
"@cloudflare/workers-types": "^4.20240725.0",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
"@types/psi": "^4.1.6",
"@types/react": "18.3.13",
"@types/react-dom": "18.3.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/rison": "^0.0.9",
"autoprefixer": "^10",
"deepmerge": "^4.3.1",
"env-cmd": "^10.1.0",
"eslint": "^8",
"eslint-config-next": "^14.2.5",
"eslint-plugin-import": "^2.29.1",
@@ -96,7 +89,8 @@
"postcss": "^8",
"psi": "^4.1.0",
"tailwindcss": "^3.4.0",
"ts-essentials": "^10.0.1",
"typescript": "^5.5.3"
"typescript": "^5.5.3",
"env-cmd": "^10.1.0",
"ts-essentials": "^10.0.1"
}
}
+18 -9
View File
@@ -1,18 +1,27 @@
import * as Sentry from '@sentry/browser';
import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
Sentry.init({
// To tree shake default integrations that we don't use
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
const client = new BrowserClient({
debug: false,
dsn,
release: process.env.SENTRY_RELEASE,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
integrations: [],
sampleRate: 0.1,
enableTracing: false,
beforeSendTransaction: () => {
return null;
},
transport: makeFetchTransport,
stackParser: defaultStackParser,
});
getCurrentScope().setClient(client);
client.init();
}
@@ -3,11 +3,13 @@ import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
init({
debug: false,
dsn,
release: process.env.SENTRY_RELEASE,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
enableTracing: false,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
@@ -16,5 +18,3 @@ if (dsn) {
},
});
}
export * from '@sentry/nextjs';
+20
View File
@@ -0,0 +1,20 @@
import { init } from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
init({
debug: false,
dsn,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
enableTracing: false,
// Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3
beforeSendTransaction: () => {
return null;
},
});
}
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/server';
import {
CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
SignatureVersion,
verifyImageSignature,
} from '@/lib/image-signatures';
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images';
resizeImage,
CloudflareImageOptions,
imagesResizingSignVersion,
checkIsSizableImageURL,
} from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge';
@@ -20,15 +20,12 @@ export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign');
// The current signature algorithm sets version as 2, but we need to support the older version as well
const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined;
if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 });
}
const signatureVersion = parseSignatureVersion(request.nextUrl.searchParams.get('sv'));
if (!signatureVersion) {
return new Response(`Invalid sv parameter`, { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Check again if the image can be sized, even though we checked when rendering the Image component
@@ -38,14 +35,15 @@ export async function GET(request: NextRequest) {
return new Response('Invalid url parameter', { status: 400 });
}
// Verify the signature
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
if (!verified) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
// For older signatures, we redirect to the url.
if (signatureVersion !== imagesResizingSignVersion) {
return Response.redirect(url, 302);
}
if (signatureVersion !== CURRENT_SIGNATURE_VERSION) {
return NextResponse.redirect(url, 302);
// Verify the signature
const verified = await verifyImageSignature(url, { signature });
if (!verified) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
}
// Cloudflare-specific options are in the cf object.
@@ -92,24 +90,6 @@ export async function GET(request: NextRequest) {
return response;
} catch (error) {
// Redirect to the original image if resizing fails
return NextResponse.redirect(url, 302);
return Response.redirect(url, 302);
}
}
/**
* Parse the image signature version from a query param. Returns null if the version is invalid.
*/
function parseSignatureVersion(input: string | null): SignatureVersion | null {
// Before introducing the sv parameter, all signatures were generated with version 0.
if (!input) {
return '0';
}
// If the query param explicitly asks for a signature version.
if (isSignatureVersion(input)) {
return input;
}
// Otherwise the version is invalid.
return null;
}
@@ -15,7 +15,7 @@ export function RocketLoaderDetector(props: { nonce: string }) {
document.addEventListener("DOMContentLoaded", () => {
if (Array.from(document.scripts).find(script => script.src.includes('rocket-loader.min.js'))) {
const alert = document.createElement('div');
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 mt-8 mx-8';
alert.className = 'p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 mt-8 mx-8';
alert.innerHTML = \`
<strong>Error in site configuration:</strong>
It looks like \${window.location.hostname} has been incorrectly configured in Cloudflare. This may lead to unexpected behavior or issues with the page loading. If you are the owner of this site, please refer to <a href="https://docs.gitbook.com/published-documentation/custom-domain/configure-dns#are-you-using-cloudflare" class="underline">GitBook's documentation</a> for steps to fix the problem.
@@ -0,0 +1,24 @@
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = getSiteContentPointer();
const { customization } = await getSiteData(pointer);
const language = getSpaceLanguage(customization);
return (
<div
className={tcls('flex-1', 'flex', 'flex-row', 'items-center', 'justify-center', 'py-9')}
>
<div className={tcls('max-w-80')}>
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
{t(language, 'notfound_title')}
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
</div>
);
}
@@ -5,29 +5,26 @@ import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { PageClientLayout } from './PageClientLayout';
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/**
* Fetch and render a page.
*/
export default async function Page(props: {
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
params: PagePathParams;
searchParams: { fallback?: string };
}) {
const { params: rawParams, searchParams: rawSearchParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
const { params, searchParams } = props;
const {
content: contentPointer,
@@ -38,7 +35,6 @@ export default async function Page(props: {
customization,
pages,
page,
ancestors,
document,
} = await getPageDataWithFallback({
pagePathParams: params,
@@ -53,12 +49,12 @@ export default async function Page(props: {
if (pathname !== rawPathname) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(await getAbsoluteHref(pathname));
redirect(absoluteHref(pathname));
} else {
notFound();
}
} else if (getPagePath(pages, page) !== rawPathname) {
redirect(await getPageHref(pages, page, linksContext));
redirect(pageHref(pages, page, linksContext));
}
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -86,18 +82,20 @@ export default async function Page(props: {
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
) : null}
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
<div className="flex flex-row-reverse justify-end grow">
<PageAside
space={space}
site={site}
customization={customization}
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={contentRefContext}
/>
<div className="flex flex-row-reverse justify-end">
{page.layout.outline ? (
<PageAside
space={space}
site={site}
customization={customization}
page={page}
document={document}
withHeaderOffset={headerOffset}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
context={contentRefContext}
/>
) : null}
<PageBody
space={space}
pointer={contentPointer}
@@ -105,9 +103,11 @@ export default async function Page(props: {
customization={customization}
context={contentRefContext}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
/>
</div>
<React.Suspense fallback={null}>
@@ -117,12 +117,8 @@ export default async function Page(props: {
);
}
export async function generateViewport({
params,
}: {
params: Promise<PagePathParams>;
}): Promise<Viewport> {
const { customization } = await fetchPageData(await params);
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
const { customization } = await fetchPageData(params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
@@ -136,12 +132,12 @@ export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
params: PagePathParams;
searchParams: { fallback?: string };
}): Promise<Metadata> {
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: await params,
searchParams: await searchParams,
pagePathParams: params,
searchParams,
});
if (!page) {
@@ -154,18 +150,16 @@ export async function generateMetadata({
.join(' | '),
description: page.description ?? '',
alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
canonical: absoluteHref(getPagePath(pages, page), true),
},
openGraph: {
images: [
customization.socialPreview.url ??
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
],
},
robots:
(await isSpaceIndexable({ space, site: site ?? null })) &&
isPageIndexable(ancestors, page)
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
? 'index, follow'
: 'noindex, nofollow',
};
@@ -189,7 +183,7 @@ async function getPageDataWithFallback(args: {
const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(await getPageHref(pages, rootPage?.page));
redirect(pageHref(pages, rootPage?.page));
}
page = rootPage?.page;
@@ -1,7 +1,6 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import React from 'react';
import * as ReactDOM from 'react-dom';
@@ -13,7 +12,7 @@ import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
import { absoluteHref, baseUrl } from '@/lib/links';
import { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
@@ -22,7 +21,6 @@ import { RocketLoaderDetector } from './RocketLoaderDetector';
import { fetchContentData } from '../fetch';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/**
* Layout when rendering the content.
@@ -30,7 +28,7 @@ export const dynamic = 'force-dynamic';
export default async function ContentLayout(props: { children: React.ReactNode }) {
const { children } = props;
const nonce = await getContentSecurityPolicyNonce();
const nonce = getContentSecurityPolicyNonce();
const {
content,
space,
@@ -44,8 +42,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
sections,
} = await fetchContentData();
const apiCtx = await api();
ReactDOM.preconnect(apiCtx.client.endpoint);
ReactDOM.preconnect(api().endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
@@ -57,51 +54,47 @@ export default async function ContentLayout(props: { children: React.ReactNode }
});
});
const queryStringTheme = await getQueryStringTheme();
return (
<NuqsAdapter>
<ClientContexts
nonce={nonce}
forcedTheme={
queryStringTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
<ClientContexts
nonce={nonce}
forcedTheme={
getQueryStringTheme() ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
>
<SpaceLayout
space={space}
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
>
<SpaceLayout
space={space}
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
>
{children}
</SpaceLayout>
{children}
</SpaceLayout>
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} nonce={nonce} />
))}
</>
) : null}
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} nonce={nonce} />
))}
</>
) : null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
</React.Suspense>
) : null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
</React.Suspense>
) : null}
<RocketLoaderDetector nonce={nonce} />
<RocketLoaderDetector nonce={nonce} />
<AdminToolbar space={space} content={content} />
</ClientContexts>
</NuqsAdapter>
<AdminToolbar space={space} content={content} />
</ClientContexts>
);
}
@@ -123,34 +116,34 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(await getBaseUrl()),
metadataBase: new URL(baseUrl()),
icons: {
icon: [
{
url:
customIcon?.light ??
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)),
absoluteHref('~gitbook/icon?size=small&theme=light', true),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url:
customIcon?.dark ??
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
absoluteHref('~gitbook/icon?size=small&theme=dark', true),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
],
},
robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow',
robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
};
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
async function getQueryStringTheme() {
const headersList = await headers();
function getQueryStringTheme() {
const headersList = headers();
const queryStringTheme = headersList.get('x-gitbook-theme');
if (!queryStringTheme) {
return null;
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server';
import { getSpace, getSite } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { absoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
@@ -11,7 +11,7 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer();
const pointer = getSiteContentPointer();
const [site, space] = await Promise.all([
getSite(pointer.organizationId, pointer.siteId),
getSpace(pointer.spaceId, pointer.siteShareKey),
@@ -20,8 +20,8 @@ export async function GET(req: NextRequest) {
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...((await isSpaceIndexable({ space, site }))
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
...(isSpaceIndexable({ space, site })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
const content = lines.join('\n');
@@ -0,0 +1,95 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import jsontoxml from 'jsontoxml';
import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { isPageIndexable } from '@/lib/seo';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer();
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
const urls = pages.map(({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const lastModified = page.updatedAt || page.createdAt;
return {
url: {
loc: absoluteHref(getPagePath(rootPages, page), true),
priority: normalizedPriority,
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
});
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
'xsi:schemaLocation':
'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
type FlatPageEntry = { page: RevisionPageDocument; depth: number };
function flattenPages(
rootPags: RevisionPage[],
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
): FlatPageEntry[] {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
): FlatPageEntry[] => {
const allowed = filter(page);
if (!allowed) {
return [];
}
return [
...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) =>
child.type === 'document' ? flattenPage(child, depth + 1) : [],
),
];
};
return rootPags.flatMap((page) =>
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
);
}
@@ -35,7 +35,7 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const pointer = await getSiteContentPointer();
const pointer = getSiteContentPointer();
const spaceId = pointer.spaceId;
const [space, { customization }] = await Promise.all([
@@ -48,7 +48,7 @@ export async function GET(req: NextRequest) {
return new ImageResponse(
(
<div
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-black', size.boxStyle)}
tw={tcls(options.theme === 'light' ? 'bg-white' : 'bg-gray-900', size.boxStyle)}
style={{
width: '100%',
height: '100%',
@@ -62,7 +62,7 @@ export async function GET(req: NextRequest) {
size.textSize,
'font-bold',
'tracking-tight',
options.theme === 'light' ? 'text-black' : 'text-white',
options.theme === 'light' ? 'text-gray-900' : 'text-white',
)}
>
{'emoji' in customization.favicon
@@ -2,100 +2,47 @@ import { CustomizationHeaderPreset } from '@gitbook/api';
import { redirect } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import colorContrast from 'postcss-color-contrast/js';
import React from 'react';
import { googleFontsMap } from '@/fonts';
import { colorContrast } from '@/lib/colors';
import { getAbsoluteHref } from '@/lib/links';
import { filterOutNullable } from '@/lib/typescript';
import { absoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge';
async function loadGoogleFont(input: { fontFamily: string; text: string; weight: 400 | 700 }) {
const { fontFamily, text, weight } = input;
if (!text.trim()) {
return null;
}
const url = new URL('https://fonts.googleapis.com/css2');
url.searchParams.set('family', `${fontFamily}:wght@${weight}`);
url.searchParams.set('text', text);
const result = await fetch(url.href);
if (!result.ok) {
return null;
}
const css = await result.text();
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
const resourceUrl = resource ? resource[1] : null;
if (resourceUrl) {
const response = await fetch(resourceUrl);
if (response.ok) {
const data = await response.arrayBuffer();
return {
name: fontFamily,
data,
style: 'normal' as const,
weight,
};
}
}
// If for some reason we can't load the font, we'll just use the default one
return null;
}
/**
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
const { space, page, customization, site } = await fetchPageData(await params);
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, site } = await fetchPageData(params);
// If user configured a custom social preview, we redirect to it.
if (customization.socialPreview.url) {
// If user configured a custom social preview, we redirect to it.
redirect(customization.socialPreview.url);
}
// Compute all text to load only the necessary fonts
const contentTitle = customization.header.logo
? ''
: getContentTitle(space, customization, site ?? null);
const pageTitle = page
? page.title.length > 64
? page.title.slice(0, 64) + '...'
: page.title
: 'Not found';
const pageDescription =
page?.description && page?.title.length <= 64
? page.description.length > 164
? page.description.slice(0, 164) + '...'
: page.description
: '';
const fontFamily = googleFontsMap[customization.styling.font] ?? 'Inter';
const regularText = pageDescription;
const boldText = `${contentTitle}${pageTitle}`;
const fonts = (
await Promise.all([
loadGoogleFont({ fontFamily, text: regularText, weight: 400 }),
loadGoogleFont({ fontFamily, text: boldText, weight: 700 }),
])
).filter(filterOutNullable);
// TODO: Support all fonts available in GitBook
// Right now this is impossible since next/font/google does not expose the cached font file
// Another option would be to use the Satori prop `loadAdditionalAsset` [example](https://github.com/vercel/satori/blob/main/playground/pages/index.tsx),
// but this prop isn't (yet) exposed through `ImageResponse`.
const interRegular = await fetch(
new URL('../../../../../../fonts/Inter/Inter-Regular.ttf', import.meta.url),
).then((res) => res.arrayBuffer());
const interBold = await fetch(
new URL('../../../../../../fonts/Inter/Inter-Bold.ttf', import.meta.url),
).then((res) => res.arrayBuffer());
const theme = customization.themes.default;
const useLightTheme = theme === 'light';
// We have no access to CSS variables, so we'll have to hardcode some values
const baseColors = { light: '#ffffff', dark: '#111827' };
const baseColors = {
light: '#ffffff',
dark: '#111827',
};
let colors = {
background: baseColors[theme],
@@ -104,10 +51,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
};
const [gridWhite, gridBlack] = await Promise.all([
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
]);
const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
let gridAsset = useLightTheme ? gridBlack : gridWhite;
@@ -145,41 +90,55 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
break;
}
const favicon = await (async () => {
const favicon = function () {
if ('icon' in customization.favicon)
return (
<img
src={customization.favicon.icon[theme]}
width={40}
height={40}
tw="mr-4"
tw={tcls('mr-4')}
alt="Icon"
/>
);
if ('emoji' in customization.favicon)
return (
<span tw="text-4xl mr-4">
<span tw={tcls('text-4xl', 'mr-4')}>
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
</span>
);
const src = await getAbsoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
return (
<img
src={absoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
)}
alt="Icon"
width={40}
height={40}
tw={tcls('mr-4')}
/>
);
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
})();
};
return new ImageResponse(
(
<div
tw={`justify-between p-20 relative w-full h-full flex flex-col bg-[${colors.background}] text-[${colors.body}]`}
style={{
fontFamily,
}}
tw={tcls(
'justify-between',
'p-20',
'relative',
'w-full',
'h-full',
'flex',
'flex-col',
`bg-[${colors.background}]`,
`text-[${colors.body}]`,
)}
>
{/* Gradient */}
<div
tw="absolute inset-0"
tw={tcls('absolute', 'inset-0')}
style={{
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
opacity: 0.5,
@@ -187,7 +146,11 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
></div>
{/* Grid */}
<img tw="absolute inset-0 w-[100vw] h-[100vh]" src={gridAsset} alt="Grid" />
<img
tw={tcls('absolute', 'inset-0', 'w-[100vw]', 'h-[100vh]')}
src={gridAsset}
alt="Grid"
/>
{/* Logo */}
{customization.header.logo ? (
@@ -201,21 +164,39 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
}
/>
) : (
<div tw="flex">
{favicon}
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
<div tw={tcls('flex')}>
{favicon()}
<h3 tw={tcls('text-4xl', 'my-0')}>
{getContentTitle(space, customization, site ?? null)}
</h3>
</div>
)}
{/* Title and description */}
<div tw="flex flex-col">
<div tw={tcls('flex', 'flex-col')}>
<h1
tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`}
tw={tcls(
'text-8xl',
'my-0',
'tracking-tight',
'leading-none',
'text-left',
`text-[${colors.title}]`,
'font-bold',
)}
>
{pageTitle}
{page
? page.title.length > 64
? page.title.slice(0, 64) + '...'
: page.title
: 'Not found'}
</h1>
{pageDescription ? (
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2>
{page?.description && page?.title.length <= 64 ? (
<h2 tw={tcls('text-4xl', 'mb-0', 'mt-8', 'w-[75%]', 'font-normal')}>
{page.description.length > 164
? page.description.slice(0, 164) + '...'
: page.description}
</h2>
) : null}
</div>
</div>
@@ -223,7 +204,20 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
{
width: 1200,
height: 630,
fonts: fonts.length ? fonts : undefined,
fonts: [
{
name: 'Inter',
data: interRegular,
weight: 400,
style: 'normal',
},
{
name: 'Inter',
data: interBold,
weight: 700,
style: 'normal',
},
],
},
);
}
@@ -1,6 +1,6 @@
'use client';
import { captureException, withScope } from '@sentry/nextjs';
import { captureException } from '@sentry/nextjs';
import React from 'react';
import { Button } from '@/components/primitives/Button';
@@ -15,12 +15,7 @@ export default function ErrorPage(props: {
const language = useLanguage();
React.useEffect(() => {
withScope((scope) => {
if ('_componentStack' in error && error._componentStack) {
scope.setExtra('componentStack', error._componentStack);
}
captureException(error);
});
captureException(error);
}, [error]);
return (
@@ -3,10 +3,10 @@ import { redirect } from 'next/navigation';
import {
getRevisionPageByPath,
getDocument,
getSpaceContentData,
getSiteData,
getSiteRedirectBySource,
getPageDocument,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
@@ -23,7 +23,7 @@ export interface PageIdParams {
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = await getSiteContentPointer();
const content = getSiteContentPointer();
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([
@@ -65,7 +65,9 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
shareKey: contentData.shareKey,
params,
});
const document = page ? await getPageDocument(contentData.space.id, page.page) : null;
const document = page?.page.documentId
? await getDocument(contentData.space.id, page.page.documentId)
: null;
return {
...contentData,
@@ -9,7 +9,7 @@ import { getSiteContentPointer } from '@/lib/pointer';
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = await getSiteContentPointer();
const pointer = getSiteContentPointer();
const { customization } = await getSiteData(pointer);
return (
@@ -133,7 +133,7 @@ export function PageControlButtons(props: {
'items-center',
'justify-center',
'text-lg',
'text-tint',
'text-dark/6',
'px-6',
'py-2',
'bg-slate-100',
@@ -12,10 +12,10 @@ import { getSiteOrSpacePointerForPDF } from './pointer';
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = await getSiteOrSpacePointerForPDF();
const pointer = getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer
? getSiteData(pointer)
: getSpaceLayoutData());
: getSpaceLayoutData(pointer.spaceId));
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -25,9 +25,9 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
/**
* Fetch all the layout data about a space at once.
*/
async function getSpaceLayoutData() {
async function getSpaceLayoutData(spaceId: string) {
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(),
getSpaceCustomization(spaceId),
[] as SpaceIntegrationScript[],
]);
@@ -4,7 +4,6 @@ import {
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
@@ -23,9 +22,8 @@ import {
getSpaceCustomization,
getSpaceContentData,
getSiteData,
getPageDocument,
} from '@/lib/api';
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -39,13 +37,12 @@ import { PrintButton } from './PrintButton';
const DEFAULT_LIMIT = 100;
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise<Metadata> {
const pointer = await getSiteOrSpacePointerForPDF();
const pointer = getSiteOrSpacePointerForPDF();
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
]);
return {
@@ -57,21 +54,19 @@ export async function generateMetadata(): Promise<Metadata> {
/**
* Render a space as a standalone HTML page that can be printed as a PDF.
*/
export default async function PDFHTMLOutput(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const pointer = await getSiteOrSpacePointerForPDF();
export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
const pointer = getSiteOrSpacePointerForPDF();
const searchParams = new URLSearchParams(await props.searchParams);
const searchParams = new URLSearchParams(props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
let currentPDFUrl = absoluteHref('~gitbook/pdf', true);
currentPDFUrl += '?' + searchParams.toString();
// Load the content,
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]);
const language = getSpaceLanguage(customization);
@@ -79,7 +74,7 @@ export default async function PDFHTMLOutput(props: {
// Compute the pages to render
const { pages, total } = selectPages(rootPages, pdfParams);
const pageIds = pages.map(
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string],
({ page }) => [page.id, pagePDFContainerId(page)] as [string, string],
);
const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id),
@@ -91,16 +86,17 @@ export default async function PDFHTMLOutput(props: {
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a
title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? (await getAbsoluteHref(''))}
href={pdfParams.back ?? absoluteHref('')}
className={tcls(
'flex',
'flex-row',
'items-center',
'justify-center',
'text-sm',
'text-tint',
'text-dark/6',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'shadow-sm',
@@ -123,9 +119,10 @@ export default async function PDFHTMLOutput(props: {
'items-center',
'justify-center',
'text-sm',
'text-tint',
'text-dark/6',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'shadow-sm',
@@ -146,11 +143,7 @@ export default async function PDFHTMLOutput(props: {
limit={pdfParams.limit ?? DEFAULT_LIMIT}
trademark={
customization.trademark.enabled ? (
<TrademarkLink
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Pdf}
/>
<TrademarkLink space={space} customization={customization} />
) : null
}
/>
@@ -163,7 +156,7 @@ export default async function PDFHTMLOutput(props: {
<React.Suspense
key={page.id}
fallback={
<PrintPage id={getPagePDFContainerId(page)}>
<PrintPage id={pagePDFContainerId(page)}>
<p>Loading...</p>
</PrintPage>
}
@@ -208,7 +201,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props;
return (
<PrintPage id={getPagePDFContainerId(page)}>
<PrintPage id={pagePDFContainerId(page)}>
<div
className={tcls(
'break-before-page',
@@ -232,10 +225,11 @@ async function PDFPageDocument(props: {
refContext: ContentRefContext;
}) {
const { space, page, refContext } = props;
const document = await getPageDocument(space.id, page);
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
return (
<PrintPage id={getPagePDFContainerId(page)}>
<PrintPage id={pagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
{page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
@@ -254,7 +248,7 @@ async function PDFPageDocument(props: {
},
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => getPagePDFContainerId(page, id),
getId: (id) => pagePDFContainerId(page, id),
}}
/>
) : null}
@@ -8,11 +8,9 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
*
* This function returns the pointer depending on the context.
*/
export async function getSiteOrSpacePointerForPDF(): Promise<
SiteContentPointer | SpaceContentPointer
> {
export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
try {
return await getSiteContentPointer();
return getSiteContentPointer();
} catch (error) {
return getSpacePointer();
}
@@ -1,39 +0,0 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData, getSpaceContentData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = await getSiteContentPointer();
const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer),
]);
const language = getSpaceLanguage(customization);
return (
<div
className={tcls(
'flex-1',
'flex',
'flex-row',
'items-center',
'justify-center',
'py-9',
'min-h-[calc(100vh-64px)] lg:min-h-fit',
)}
>
<div className={tcls('max-w-80')}>
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
{t(language, 'notfound_title')}
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
{/* Track the page not found as a page view */}
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
</div>
);
}
@@ -1,154 +0,0 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import { ListItem, Paragraph, Root, RootContent } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import { NextRequest } from 'next/server';
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
import { getSiteStructureSections } from '@/lib/utils';
export const runtime = 'edge';
/**
* Generate a llms.txt file for the site.
*/
export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer();
const { structure: siteStructure, site } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('llms.txt is only served from the root of the site', { status: 404 });
}
const tree: Root = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: site.title }],
},
...(await getNodesFromSiteStructure(siteStructure)),
],
};
return new Response(
toMarkdown(tree, {
bullet: '-',
}),
{
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
},
);
}
/**
* Get MDAST nodes from site structure.
*/
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
switch (siteStructure.type) {
case 'sections':
return getNodesFromSections(getSiteStructureSections(siteStructure));
case 'siteSpaces':
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
default:
assertNever(siteStructure);
}
}
/**
* Get MDAST nodes from site sections.
*/
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
const all = await Promise.all(
siteSections.map(async (siteSection): Promise<RootContent[]> => {
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
heading: false,
});
return [
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSection.title }],
},
...siteSpaceNodes,
];
}),
);
return all.flat();
}
/**
* Get MDAST nodes from site spaces.
*/
async function getNodesFromSiteSpaces(
siteSpaces: SiteSpace[],
options: {
/**
* Includes a heading for each site space.
*/
heading?: boolean;
},
): Promise<RootContent[]> {
const all = await Promise.all(
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
const siteSpaceUrl = siteSpace.urls.published;
if (!siteSpaceUrl) {
return [];
}
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
metadata: false,
});
const pages = getIndexablePages(rootPages);
const listChildren = await Promise.all(
pages.map(async ({ page }): Promise<ListItem> => {
const url = await getAbsoluteHref(
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
true,
);
const children: Paragraph['children'] = [
{
type: 'link',
url,
children: [{ type: 'text', value: page.title }],
},
];
if (page.description) {
children.push({ type: 'text', value: `: ${page.description}` });
}
return {
type: 'listItem',
children: [{ type: 'paragraph', children }],
};
}),
);
const nodes: RootContent[] = [];
if (options.heading) {
nodes.push({
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSpace.title }],
});
}
nodes.push({
type: 'list',
spread: false,
children: listChildren,
});
return nodes;
}),
);
return all.flat();
}
@@ -1,66 +0,0 @@
import jsontoxml from 'jsontoxml';
import { getRevisionPages, getSpace } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current section / space.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const revisionId =
pointer.revisionId ?? (await getSpace(pointer.spaceId, pointer.siteShareKey)).revision;
const rootPages = await getRevisionPages(pointer.spaceId, revisionId, { metadata: false });
const pages = getIndexablePages(rootPages);
const urls = await Promise.all(
pages.map(async ({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const lastModified = page.updatedAt || page.createdAt;
const url: { loc: string; priority: number; lastmod?: string } = {
priority: normalizedPriority,
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
};
if (lastModified) {
url.lastmod = new Date(lastModified).toISOString();
}
return { url };
}),
);
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
@@ -1,98 +0,0 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import jsontoxml from 'jsontoxml';
import { getPublishedContentSite } from '@/lib/api';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { filterOutNullable } from '@/lib/typescript';
import { getSiteStructureSections } from '@/lib/utils';
export const runtime = 'edge';
/**
* Generate a root sitemap that point to all sitemap-pages.xml.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const { structure: siteStructure } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('Root sitemap is only served from the root of the site', {
status: 404,
});
}
const urls = await getUrlsFromSiteStructure(siteStructure);
const xml = jsontoxml(
[
{
name: 'sitemapindex',
children: urls.map((url) => ({
name: 'sitemap',
children: [{ name: 'loc', text: url }],
})),
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
/**
* Get Sitemap URLs from site structure.
*/
async function getUrlsFromSiteStructure(siteStructure: SiteStructure): Promise<string[]> {
switch (siteStructure.type) {
case 'sections':
return getUrlsFromSiteSections(getSiteStructureSections(siteStructure));
case 'siteSpaces':
return getUrlsFromSiteSpaces(siteStructure.structure);
default:
assertNever(siteStructure);
}
}
/**
* Get Sitemap URLs from site sections.
*/
async function getUrlsFromSiteSections(siteSections: SiteSection[]): Promise<string[]> {
const urls = await Promise.all(
siteSections.map(async (siteSection) => getUrlsFromSiteSpaces(siteSection.siteSpaces), []),
);
return urls.flat();
}
/**
* Get Sitemap URLs from site spaces.
*/
async function getUrlsFromSiteSpaces(siteSpaces: SiteSpace[]): Promise<string[]> {
const urls = await Promise.all(
siteSpaces.map(async (siteSpace) => {
if (!siteSpace.urls.published) {
return null;
}
const url = new URL(siteSpace.urls.published);
url.pathname = joinPath(url.pathname, 'sitemap-pages.xml');
return url.toString();
}, []),
);
return urls.filter(filterOutNullable);
}
@@ -1,6 +1,5 @@
import { Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { headers } from 'next/headers';
import React from 'react';
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
@@ -15,7 +14,33 @@ interface AdminToolbarProps {
space: Space;
}
function ToolbarLayout(props: { children: React.ReactNode }) {
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const toolbar = (() => {
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
spaceId={content.spaceId}
changeRequestId={content.changeRequestId}
/>
);
}
if (content.revisionId) {
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
}
return null;
})();
if (!toolbar) {
return null;
}
return (
<div
className={tcls(
@@ -26,88 +51,51 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
'transform',
'-translate-x-1/2',
'rounded-full',
'bg-tint-12/9',
'dark:bg-tint-1/9',
'bg-dark-1/9',
'shadow-lg',
'min-h-10',
'min-w-40',
'p-2',
'max-w-md',
'border-tint-12/1',
'backdrop-blur-md',
'border-dark-1',
'backdrop-blur-sm',
)}
>
<React.Suspense fallback={null}>{props.children}</React.Suspense>
<React.Suspense fallback={null}>{toolbar}</React.Suspense>
</div>
);
}
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const mode = await headers().get('x-gitbook-mode');
if (mode === 'multi-id') {
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
return null;
}
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
spaceId={content.spaceId}
changeRequestId={content.changeRequestId}
/>
);
}
if (content.revisionId) {
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
}
return null;
}
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
const { spaceId, changeRequestId } = props;
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
if (!changeRequest) {
return null;
}
return (
<ToolbarLayout>
<Toolbar>
<Toolbar>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-xs text-light/8 dark:text-light/8">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-xs text-tint-2 dark:text-tint-11">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
<RefreshChangeRequestButton
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
);
}
@@ -119,35 +107,31 @@ async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
});
return (
<ToolbarLayout>
<Toolbar>
<Toolbar>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-xs text-light/8 dark:text-light/8">{revision.git.message}</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-xs text-tint-2 dark:text-tint-11">
{revision.git.message}
</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
) : null}
</ToolbarButtonGroups>
</Toolbar>
);
}
@@ -19,8 +19,8 @@ export function Toolbar(props: { children: React.ReactNode }) {
'py-1',
'rounded-full',
'truncate',
'text-tint-1',
'dark:text-tint-12',
'text-light',
'dark:text-light',
)}
>
{children}
@@ -50,11 +50,12 @@ export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
'gap-1',
'text-sm',
'rounded-full',
'hover:bg-dark-1',
'hover:text-white',
'truncate',
'text-tint-1', // Equal to text-tint-strong in dark mode
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
'hover:bg-tint-12',
'dark:hover:bg-tint-1',
'text-light',
'dark:text-light',
'dark:hover:bg-dark-2',
'hover:shadow-lg',
'cursor-pointer',
)}
+41 -38
View File
@@ -1,21 +1,12 @@
'use client';
import {
SiteAds,
SiteAdsStatus,
SiteInsightsAdPlacement,
SiteInsightsAd,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
import * as React from 'react';
import { t, useLanguage } from '@/intl/client';
import { ClassValue, tcls } from '@/lib/tailwind';
import { renderAd } from './renderAd';
import { useHasBeenInViewport } from '../hooks/useHasBeenInViewport';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
/**
* Zone ID provided by BuySellAds for the preview.
@@ -37,34 +28,46 @@ export function Ad({
}: {
zoneId: string | null;
spaceId: string;
placement: SiteInsightsAdPlacement;
placement: string;
ignore: boolean;
style?: ClassValue;
siteAdsStatus?: SiteAds['status'];
mode?: 'classic' | 'auto' | 'cover';
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [ad, setAd] = React.useState<
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
>(undefined);
const trackEvent = useTrackEvent();
const [visible, setVisible] = React.useState(false);
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
// Track display of the ad
// Observe the container visibility
React.useEffect(() => {
if (ad?.insightsAd) {
trackEvent({
type: 'ad_display',
ad: ad.insightsAd,
});
if (!containerRef.current) {
return;
}
}, [ad, trackEvent]);
const hasBeenInViewport = useHasBeenInViewport(containerRef, { threshold: 0.1 });
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
}
},
{
root: null,
rootMargin: '0px',
threshold: 0.1,
},
);
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, []);
// When the container is visible,
// track an impression on the ad and fetch it
React.useEffect(() => {
if (!hasBeenInViewport) {
if (!visible) {
return;
}
@@ -108,13 +111,13 @@ export function Ad({
return () => {
cancelled = true;
};
}, [hasBeenInViewport, zoneId, ignore, placement, mode, siteAdsStatus]);
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
return (
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? (
<>
{ad.children}
{ad}
<AdSponsoredLink spaceId={spaceId} />
</>
) : null}
@@ -125,7 +128,6 @@ export function Ad({
function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content');
@@ -133,18 +135,19 @@ function AdSponsoredLink(props: { spaceId: string }) {
viaUrl.searchParams.set('utm_campaign', spaceId);
return (
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}>
<Link
target="_blank"
href={viaUrl.toString()}
className={tcls('hover:underline')}
insights={{
type: 'trademark_click',
placement: SiteInsightsTrademarkPlacement.Ad,
}}
>
<p
className={tcls(
'mt-2',
'mr-2',
'text-xs',
'text-right',
'text-dark/5',
'dark:text-light/5',
)}
>
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
{t(language, 'sponsored_via_gitbook')}
</Link>
</a>
</p>
);
}
@@ -1,65 +1,55 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdItem } from './types';
import { Link } from '../primitives';
/**
* Classic rendering for an ad.
*/
export async function AdClassicRendering({
ad,
insightsAd,
}: {
ad: AdItem;
insightsAd: SiteInsightsAd | null;
}) {
const smallImgSrc =
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
const logoSrc =
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
export function AdClassicRendering({ ad }: { ad: AdItem }) {
return (
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
<a
className={tcls(
'flex',
'flex-col',
'gap-4',
'bg-tint',
'text-tint',
'hover:text-tint-strong',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg',
'p-4',
)}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
{smallImgSrc && 'smallImage' in ad ? (
{'smallImage' in ad ? (
<div>
<img alt="Ads logo" className={tcls('rounded-md')} src={smallImgSrc} />
<img
alt="Ads logo"
className={tcls('rounded-md')}
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
/>
</div>
) : logoSrc && 'logo' in ad ? (
) : (
<div
className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }}
>
<img alt="Ads logo" src={logoSrc} />
<img
alt="Ads logo"
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
</div>
) : null}
)}
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
</Link>
</a>
);
}
@@ -1,4 +1,3 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
@@ -6,41 +5,27 @@ import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdCover } from './types';
import { Link } from '../primitives';
/**
* Cover rendering for an ad.
*/
export async function AdCoverRendering({
ad,
insightsAd,
}: {
ad: AdCover;
insightsAd: SiteInsightsAd | null;
}) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
export function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return (
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
<a
className={tcls(
'group/ad',
'relative',
'flex',
'flex-col',
'gap-4',
'bg-tint-subtle',
'text-tint',
'hover:text-tint-strong',
'bg-light-2',
'text-dark/7',
'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg',
'p-4',
'overflow-hidden',
@@ -48,6 +33,8 @@ export async function AdCoverRendering({
)}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
<div
className={tcls(
@@ -119,6 +106,6 @@ export async function AdCoverRendering({
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}}
/>
</Link>
</a>
);
}
@@ -1,6 +1,5 @@
'use server';
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers';
import { AdClassicRendering } from './AdClassicRendering';
@@ -21,7 +20,7 @@ interface FetchLiveAdOptions {
/** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */
placement: SiteInsightsAdPlacement;
placement: string;
/** If true, we'll not track it as an impression */
ignore: boolean;
}
@@ -41,35 +40,23 @@ interface FetchPlaceholderAdOptions {
export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
const { ad } = result;
const insightsAd: SiteInsightsAd | null =
options.source === 'live'
? {
placement: options.placement,
zoneId: options.zoneId,
domain: 'company' in ad ? ad.company : '',
}
: null;
return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
return (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
);
}
async function fetchAd({
@@ -77,7 +64,7 @@ async function fetchAd({
placement,
ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
const { ip, userAgent } = await getUserAgentAndIp();
const { ip, userAgent } = getUserAgentAndIp();
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
@@ -99,8 +86,8 @@ async function fetchAd({
return null;
}
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
const { ip } = await getUserAgentAndIp();
function getPlaceholderAd(): { ad: AdItem; ip: string } {
const { ip } = getUserAgentAndIp();
return {
ad: {
@@ -132,8 +119,8 @@ async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
};
}
async function getUserAgentAndIp() {
const headersSet = await headers();
function getUserAgentAndIp() {
const headersSet = headers();
const ip =
headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ??
@@ -11,8 +11,5 @@ export async function hasContentBeenUpdated(props: {
revisionId: string;
}) {
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId;
}
@@ -6,10 +6,9 @@ import * as React from 'react';
import { Button } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
import { tcls } from '@/lib/tailwind';
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
/**
* Toast to accept or reject the use of cookies.
*/
@@ -44,11 +43,11 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
className={tcls(
'fixed',
'z-10',
'bg-tint-base',
'bg-white',
'rounded',
'straight-corners:rounded-none',
'ring-1',
'ring-tint-subtle',
'ring-dark/2',
'shadow-1xs',
'p-4',
'pr-8',
@@ -58,6 +57,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'max-w-md',
'text-balance',
'sm:left-auto',
'dark:ring-light/2',
'dark:bg-dark',
)}
>
<p id={describedById} className={tcls('text-sm')}>
@@ -66,13 +67,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'cookies_prompt',
<a
href={privacyPolicy}
className={tcls(
'text-primary-subtle',
'hover:text-primary',
'contrast-more:text-primary',
'contrast-more:hover:text-primary-strong',
'underline',
)}
className={tcls('text-primary-500', 'hover:text-primary-600', 'underline')}
>
{t(language, 'cookies_prompt_privacy')}
</a>,
@@ -92,7 +87,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'justify-center',
'items-center',
'rounded-sm',
'hover:bg-tint-hover',
'hover:bg-dark/2',
'dark:hover:bg-light/1',
)}
title={tString(language, 'cookies_close')}
>
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
import { InlineProps } from '../Inline';
import { Inlines } from '../Inlines';
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
const { inline, context, document, children } = props;
const fragment = getNodeFragmentByType(inline, 'annotation-body');
@@ -31,32 +31,38 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
className={tcls(
'text-sm',
'max-w-[280px]',
'bg-tint',
'bg-light',
'ring-1',
'ring-tint',
'ring-dark/2',
'rounded',
'shadow-1xs',
'shadow-tint-12/1',
'dark:shadow-tint-1/2',
'shadow-dark/1',
'p-3',
'[&_p]:leading-snug',
'dark:bg-dark',
'dark:ring-light/2',
'dark:shadow-dark/4',
'-outline-offset-2',
'outline-2',
'outline-primary/8',
'z-20',
)}
sideOffset={4}
sideOffset={5}
>
{body}
<Popover.Arrow asChild>
<svg
width="100%"
viewBox="0 0 8 5"
preserveAspectRatio="xMaxYMid meet"
className={tcls(
'relative',
'z-[2]',
'fill-tint-3', // Same as bg-tint
'stroke-tint-7', // Same as ring-tint
'fill-light',
'stroke-dark/2',
'[paint-order:stroke_fill]',
'dark:fill-dark',
'dark:stroke-light/2',
)}
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -1,6 +1,6 @@
import { DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api';
import { DocumentBlockContentRef } from '@gitbook/api';
import { Card } from '@/components/primitives';
import { Card, Emoji } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references';
@@ -12,7 +12,7 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
const resolved = await context.resolveContentRef(block.data.ref, {
resolveAnchorText: true,
iconStyle: ['text-xl', 'text-tint'],
iconStyle: ['text-xl', 'text-dark/6', 'dark:text-light/6'],
});
if (!resolved) {
@@ -34,13 +34,6 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
href={resolved.href}
title={resolved.text}
style={style}
insights={{
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}}
/>
);
}
@@ -55,7 +48,7 @@ async function SpaceRefCard(
return null;
}
const { customization: spaceCustomization } = await getSpaceCustomization();
const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
@@ -23,7 +23,6 @@ export function Caption(
fit?: boolean;
wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
} & DocumentContextProps,
) {
const {
@@ -32,18 +31,22 @@ export function Caption(
block,
context,
fit = false,
withBorder = false,
wrapperStyle = [
'relative',
'overflow-hidden',
'rounded',
'straight-corners:rounded-none',
'after:block',
'after:absolute',
'after:-inset-[0]',
'after:border-dark/2',
'after:border',
'after:rounded',
'straight-corners:after:rounded-none',
'dark:after:border-light/1',
'dark:after:mix-blend-plus-lighter',
'after:pointer-events-none',
fit ? 'w-fit' : null,
withBorder
? 'rounded straight-corners:rounded-none after:border-tint-subtle after:border after:rounded straight-corners:after:rounded-none dark:after:mix-blend-plus-lighter after:pointer-events-none'
: null,
],
style,
} = props;
@@ -62,7 +65,15 @@ export function Caption(
return (
<picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
<figcaption className={tcls('text-sm', 'text-center', 'mt-2', 'text-tint')}>
<figcaption
className={tcls(
'text-sm',
'text-center',
'mt-2',
'text-dark/7',
'dark:text-light/7',
)}
>
<Inlines
nodes={captionParagraph.nodes}
document={document}

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