Compare commits

..

4 Commits

Author SHA1 Message Date
Vib Bhardwaj 7ec64dad58 rm public 2024-12-26 20:16:02 +00:00
Vib Bhardwaj 326d674254 remove next assets 2024-12-26 20:14:00 +00:00
Vib Bhardwaj a6a78ce5ac ignore shiki 2024-12-26 14:18:38 +00:00
Vib Bhardwaj 56cb2f4c9a wip 2024-12-23 15:29:31 +00:00
316 changed files with 8061 additions and 73957 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"ignore": [
"node_modules",
"dist",
"public"
]
}
+1 -1
View File
@@ -1 +1 @@
1.2.1 1.1.40
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Adapt code to pull token from customer backend generated custom cookies
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': minor
---
Add an optional client context to get a callback called when the Scalar client is opened for a block.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve contrast of search box placeholder
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track events for site insights using the new dedicated API.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve colour contrast of list item decoration
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-contentkit': minor
'gitbook': minor
---
Send redirectOnError param to getPublishedContent when token is pulled from cookie
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track clicks on links (header, footer, content) for site insights.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix emojis display
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Prevent codemirror from loading multiple versions in scalar
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix variant selector contrast for non-default themes
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Updated scalar depdenency
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix bullet list display on full size blocks
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track an event into site insights when visitor is opening the Scalar API client.
+11 -172
View File
@@ -18,8 +18,7 @@ jobs:
checks: write checks: write
statuses: write statuses: write
outputs: outputs:
deployment-url: ${{ steps.deploy.outputs.deployment-url }} deployment_url: ${{ steps.deploy.outputs.deployment-url }}
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,14 +38,12 @@ jobs:
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
if: 1 && !startsWith(github.ref, 'refs/heads/main') if: 1 && !startsWith(github.ref, 'refs/heads/main')
- name: Build Next.js with next-on-pages - name: Build Next.js with next-on-pages
run: bun run turbo build:cloudflare run: bun run build:cloudflare
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
SENTRY_DSN: ${{ vars.SENTRY_DSN }} SENTRY_DSN: ${{ vars.SENTRY_DSN }}
SENTRY_RELEASE: ${{ github.sha }}
- id: deploy - id: deploy
name: Deploy to Cloudflare name: Deploy to Cloudflare
uses: cloudflare/wrangler-action@v3.11.0 uses: cloudflare/wrangler-action@v3.11.0
@@ -75,159 +72,26 @@ jobs:
-H "Accept: application/vnd.github.v3+json" \ -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"}' \ -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 }} 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 - name: Find GitHub Comment
uses: peter-evans/find-comment@v3 uses: peter-evans/find-comment@v3
id: fc id: fc
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with: with:
issue-number: ${{ github.event.pull_request.number }} issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]' comment-author: 'github-actions[bot]'
body-includes: 'Summary of the deployments' body-includes: GitBook Preview
- name: Create or update GitHub comment - name: Create or update GitHub comment
uses: peter-evans/create-or-update-comment@v4 uses: peter-evans/create-or-update-comment@v4
if: 1 && !startsWith(github.ref, 'refs/heads/main')
with: with:
comment-id: ${{ steps.fc.outputs.comment-id }} comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }} issue-number: ${{ github.event.pull_request.number }}
body: | body: |
Summary of the deployments: **GitBook Preview**
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
### Version 1 (production) PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
| 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) |
edit-mode: replace edit-mode: replace
visual-testing: visual-testing:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -245,7 +109,7 @@ jobs:
- name: Run Playwright tests - name: Run Playwright tests
run: bun e2e run: bun e2e
env: env:
BASE_URL: ${{needs.deploy.outputs.deployment-url}} BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
@@ -253,31 +117,6 @@ jobs:
name: playwright-test-results name: playwright-test-results
path: packages/gitbook/test-results/ path: packages/gitbook/test-results/
retention-days: 3 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: pagespeed-testing:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: PageSpeed Testing name: PageSpeed Testing
@@ -294,7 +133,7 @@ jobs:
- name: Run pagespeed tests - name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env: env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}} DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }} PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format: format:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created # Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761 # https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview: release-preview:
-4
View File
@@ -1,6 +1,5 @@
.next .next
.vercel .vercel
.open-next
# Generated # Generated
packages/emoji-codepoints/index.ts packages/emoji-codepoints/index.ts
@@ -9,6 +8,3 @@ packages/icons/src/data/*.json
# Build files # Build files
dist/ 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 ### Prerequisites
- Node.js (Version: >=20.6) - Node.js (Version: >=18.x)
- Use nvm for easy Node management - Bun
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
### Set up ### 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. 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 ## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request. GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/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
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.
+43 -42
View File
@@ -1,44 +1,45 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "type": "module",
"@changesets/cli": "^2.27.12", "devDependencies": {
"prettier": "^3.0.3", "@changesets/cli": "^2.27.7",
"turbo": "^2.4.1-canary.2", "prettier": "^3.0.3",
"vercel": "^39.3.0" "turbo": "^2.1.2"
}, },
"packageManager": "bun@1.1.18", "packageManager": "bun@1.1.18",
"overrides": { "patchedDependencies": {
"@codemirror/state": "6.4.1", "@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
"react": "18.3.1", "@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
"react-dom": "18.3.1" },
}, "overrides": {
"private": true, "@codemirror/state": "6.4.1"
"scripts": { },
"dev": "turbo run dev", "private": true,
"dev:v2": "turbo run dev:v2", "scripts": {
"build": "turbo run build", "dev": "turbo run dev",
"build:v2": "turbo run build:v2", "build": "turbo run build",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules", "build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint", "lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix", "lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"format": "prettier ./ --ignore-unknown --write", "format": "prettier ./ --ignore-unknown --write",
"format:check": "prettier ./ --ignore-unknown --list-different", "format:check": "prettier ./ --ignore-unknown --list-different",
"unit": "turbo run unit", "unit": "turbo run unit",
"e2e": "turbo run e2e", "e2e": "turbo run e2e",
"e2e-customers": "turbo run e2e-customers", "changeset": "changeset",
"changeset": "changeset", "release": "turbo run release && changeset publish",
"release": "turbo run release && changeset publish", "release:preview": "turbo run release:preview",
"release:preview": "turbo run release:preview", "download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local", "clean": "turbo run clean"
"clean": "turbo run clean" },
}, "workspaces": [
"workspaces": [ "packages/*"
"packages/*" ],
], "dependencies": {
"patchedDependencies": { "@babel/cli": "^7.26.4",
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch", "@babel/preset-env": "^7.26.0",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch" "@babel/preset-react": "^7.26.3",
} "tsconfig-paths": "^4.2.0"
}
} }
File diff suppressed because it is too large Load Diff
-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`
-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" }
-170
View File
@@ -1,175 +1,5 @@
# gitbook # 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 ## 0.5.0
### Minor Changes ### 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,12 +1,21 @@
import { argosScreenshot } from '@argos-ci/playwright';
import { import {
CustomizationBackground, CustomizationBackground,
CustomizationCorners, CustomizationCorners,
CustomizationFont,
CustomizationHeaderPreset, CustomizationHeaderPreset,
CustomizationIconsStyle, CustomizationIconsStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle, CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api'; } from '@gitbook/api';
import { expect } from '@playwright/test'; import { test, expect, Page, BrowserContext } from '@playwright/test';
import deepMerge from 'deepmerge';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import { import {
getVisitorAuthCookieName, getVisitorAuthCookieName,
@@ -14,19 +23,50 @@ import {
VISITOR_TOKEN_COOKIE, VISITOR_TOKEN_COOKIE,
} from '@/lib/visitor-token'; } from '@/lib/visitor-token';
import { import { getContentTestURL } from '../tests/utils';
allDeprecatedThemePresets,
allLocales, interface Test {
allSidebarBackgroundStyles, name: string;
allThemeModes, url: string; // URL to visit for testing
allThemes, cookies?: Parameters<BrowserContext['addCookies']>[0];
allTintColors, run?: (page: Page) => Promise<unknown>; // The test to run
getCustomizationURL, fullPage?: boolean; // Whether the test should be fullscreened during testing
headerLinks, screenshot?: false; // Should a screenshot be stored
runTestCases, only?: boolean; // Only run this test
TestsCase, }
waitForCookiesDialog,
} from './util'; interface TestsCase {
name: string;
baseUrl: string;
tests: Array<Test>;
}
const allLocales: CustomizationLocale[] = [
CustomizationLocale.Fr,
CustomizationLocale.Es,
CustomizationLocale.Ja,
CustomizationLocale.Zh,
];
const allThemeModes: CustomizationThemeMode[] = [
CustomizationThemeMode.Light,
CustomizationThemeMode.Dark,
];
const allThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Default,
CustomizationHeaderPreset.Bold,
CustomizationHeaderPreset.Contrast,
CustomizationHeaderPreset.Custom,
];
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[] = [ const testCases: TestsCase[] = [
{ {
@@ -50,28 +90,19 @@ const testCases: TestsCase[] = [
{ {
name: 'Search', name: 'Search',
url: '?q=', 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', name: 'Search Results',
url: '?q=gitbook', url: '?q=gitbook',
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible(); await page.waitForSelector('[data-test="search-results"]');
}, },
}, },
{ {
name: 'AI Search', name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true', url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({ await page.waitForSelector('[data-test="search-ask-answer"]');
timeout: 15_000,
});
}, },
screenshot: false, screenshot: false,
}, },
@@ -232,28 +263,19 @@ const testCases: TestsCase[] = [
{ {
name: 'Search', name: 'Search',
url: '?q=', 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', name: 'Search Results',
url: '?q=gitbook', url: '?q=gitbook',
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible(); await page.waitForSelector('[data-test="search-results"]');
}, },
}, },
{ {
name: 'AI Search', name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true', url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => { run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({ await page.waitForSelector('[data-test="search-ask-answer"]');
timeout: 15_000,
});
}, },
screenshot: false, screenshot: false,
}, },
@@ -282,9 +304,6 @@ const testCases: TestsCase[] = [
{ {
name: 'PDF', name: 'PDF',
url: '~gitbook/pdf?limit=10', url: '~gitbook/pdf?limit=10',
screenshot: {
waitForTOCScrolling: false,
},
}, },
], ],
}, },
@@ -307,38 +326,11 @@ const testCases: TestsCase[] = [
url: 'blocks/block-images', url: 'blocks/block-images',
run: waitForCookiesDialog, run: waitForCookiesDialog,
fullPage: true, 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', name: 'Inline Images',
url: 'blocks/inline-images', url: 'blocks/inline-images',
run: async (page) => { run: waitForCookiesDialog,
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';
}
}
});
},
}, },
{ {
name: 'Tabs', name: 'Tabs',
@@ -409,18 +401,6 @@ const testCases: TestsCase[] = [
{ {
name: 'Math', name: 'Math',
url: 'blocks/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', name: 'Files',
@@ -474,9 +454,6 @@ const testCases: TestsCase[] = [
name: 'With cover and no TOC', name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc', url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog, run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
}, },
{ {
name: 'With icon', name: 'With icon',
@@ -488,23 +465,23 @@ const testCases: TestsCase[] = [
{ {
name: 'Customization', name: 'Customization',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
tests: allThemeModes.flatMap((themeMode) => [ tests: allThemeModes.flatMap((theme) => [
{ {
name: `Without header - Theme mode ${themeMode}`, name: `Without header - Theme ${theme}`,
url: getCustomizationURL({ url: getCustomizationURL({
header: { header: {
preset: CustomizationHeaderPreset.None, preset: CustomizationHeaderPreset.None,
links: [], links: [],
}, },
themes: { themes: {
default: themeMode, default: theme,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{ {
name: `With duotone icons - Theme mode ${themeMode}`, name: `With duotone icons - Theme ${theme}`,
url: url:
'page-options/page-with-icon' + 'page-options/page-with-icon' +
getCustomizationURL({ getCustomizationURL({
@@ -512,108 +489,119 @@ const testCases: TestsCase[] = [
icons: CustomizationIconsStyle.Duotone, icons: CustomizationIconsStyle.Duotone,
}, },
themes: { themes: {
default: themeMode, default: theme,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{ {
name: `With header buttons - Theme mode ${themeMode}`, name: `With header buttons - Theme ${theme}`,
url: getCustomizationURL({ url: getCustomizationURL({
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
links: headerLinks, 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',
},
],
}, },
themes: { themes: {
default: themeMode, default: theme,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
{ {
name: `Without tint - Default preset - Theme mode ${themeMode}`, name: `Without tint - Default preset - Theme ${theme}`,
url: getCustomizationURL({ url: getCustomizationURL({
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
links: headerLinks, 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',
},
],
}, },
themes: { themes: {
default: themeMode, default: theme,
toggeable: false, toggeable: false,
}, },
}), }),
run: waitForCookiesDialog, run: waitForCookiesDialog,
}, },
// New site themes ...allThemePresets.flatMap((preset) => ({
...allThemes.flatMap((theme) => [ name: `With tint - Preset ${preset} - Theme ${theme}`,
...allTintColors.flatMap((tint) => [ url: getCustomizationURL({
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({ styling: {
name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`, tint: { color: { light: '#346DDB', dark: '#346DDB' } },
url: getCustomizationURL({ },
styling: { header: {
theme, preset,
...(tint.value ? { tint: { color: tint.value } } : {}), ...(preset === CustomizationHeaderPreset.Custom
sidebar: { ? {
background: sidebarStyle, backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
list: CustomizationSidebarListStyle.Default, linkColor: { light: '#4DDE98', dark: '#0C693D' },
}, }
: {}),
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
}, },
header: { {
links: headerLinks, title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
}, },
themes: { ],
default: themeMode, },
toggeable: false, themes: {
}, default: theme,
}), toggeable: false,
run: waitForCookiesDialog, },
})), }),
]), 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}`, name: `With tint - Legacy background match - Theme ${theme}`,
url: getCustomizationURL({ url: getCustomizationURL({
styling: { styling: {
background: CustomizationBackground.Match, background: CustomizationBackground.Match,
}, },
header: { header: {
preset: CustomizationHeaderPreset.Default, preset: CustomizationHeaderPreset.Default,
links: headerLinks, 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',
},
],
}, },
themes: { themes: {
default: themeMode, default: theme,
toggeable: false, toggeable: false,
}, },
}), }),
@@ -642,14 +630,11 @@ const testCases: TestsCase[] = [
run: async (page) => { run: async (page) => {
const sharedSpaceLink = page.locator('a.underline'); const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click(); await sharedSpaceLink.click();
await expect( expect(page.locator('h1')).toHaveText('shared');
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const url = page.url(); const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page expect(url.endsWith('/shared')).toBeTruthy(); // correct page
}, },
screenshot: false,
}, },
], ],
}, },
@@ -661,15 +646,13 @@ const testCases: TestsCase[] = [
name: 'Navigation to shared space', name: 'Navigation to shared space',
url: '', url: '',
run: async (page) => { run: async (page) => {
await page.locator('a.underline').click(); const sharedSpaceLink = page.locator('a.underline');
await expect( await sharedSpaceLink.click();
page.getByRole('heading', { level: 1, name: 'shared' }), expect(page.locator('h1')).toHaveText('shared');
).toBeVisible();
const url = page.url(); const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page expect(url.endsWith('/shared')).toBeTruthy(); // correct page
}, },
screenshot: false,
}, },
], ],
}, },
@@ -683,7 +666,6 @@ const testCases: TestsCase[] = [
run: async (page) => { run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO'); await expect(page.locator('h1')).toHaveText('SSO');
}, },
screenshot: false,
}, },
], ],
}, },
@@ -703,7 +685,6 @@ const testCases: TestsCase[] = [
page.getByText('Authentication missing to access this content'), page.getByText('Authentication missing to access this content'),
).toBeVisible(); ).toBeVisible();
}, },
screenshot: false,
}, },
], ],
}, },
@@ -726,12 +707,7 @@ const testCases: TestsCase[] = [
); );
return `first?jwt_token=${token}`; return `first?jwt_token=${token}`;
})(), })(),
run: async (page) => { run: waitForCookiesDialog,
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
}, },
{ {
name: 'Second', name: 'Second',
@@ -748,12 +724,7 @@ const testCases: TestsCase[] = [
); );
return `second?jwt_token=${token}`; return `second?jwt_token=${token}`;
})(), })(),
run: async (page) => { run: waitForCookiesDialog,
await expect(
page.getByRole('heading', { level: 1, name: 'second' }),
).toBeVisible();
},
screenshot: false,
}, },
], ],
}, },
@@ -867,12 +838,7 @@ const testCases: TestsCase[] = [
); );
return `first?jwt_token=${token}`; return `first?jwt_token=${token}`;
})(), })(),
run: async (page) => { run: waitForCookiesDialog,
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
}, },
{ {
name: 'Custom page', name: 'Custom page',
@@ -1348,4 +1314,110 @@ const testCases: TestsCase[] = [
}, },
]; ];
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, 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);
}
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 {
/**
* 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: {
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()}`;
}
-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: { env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7), BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '', SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development', SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX, GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL, GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,
}, },
webpack(config, { dev, webpack }) { webpack(config, { dev, webpack }) {
@@ -69,15 +67,19 @@ module.exports = withSentryConfig(
}, },
}, },
{ {
release: process.env.SENTRY_RELEASE, silent: true,
org: process.env.SENTRY_ORG, org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT, 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) // Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true, widenClientFileUpload: true,
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring', tunnelRoute: '/~gitbook/monitoring',
// Don't hide source maps from generated client bundles
hideSourceMaps: false,
disableLogger: true, disableLogger: true,
automaticVercelMonitors: false,
}, },
); );
+14 -18
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitbook", "name": "gitbook",
"version": "0.6.1", "version": "0.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "env-cmd --silent -f ../../.env.local next dev", "dev": "env-cmd --silent -f ../../.env.local next dev",
@@ -9,29 +9,26 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"e2e": "playwright test e2e/internal.spec.ts", "e2e": "playwright test",
"e2e-customers": "playwright test e2e/customers.spec.ts",
"unit": "bun test {src,packages}", "unit": "bun test {src,packages}",
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math", "generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons", "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": { "dependencies": {
"@gitbook/api": "^0.93.0", "@gitbook/api": "^0.85.0",
"@gitbook/cache-do": "workspace:*", "@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*", "@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*", "@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*", "@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*", "@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "8.35.0", "@sentry/nextjs": "^7.94.1",
"@sindresorhus/fnv1a": "^3.1.0", "@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.10",
"@upstash/redis": "^1.27.1", "@upstash/redis": "^1.27.1",
"ajv": "^8.12.0", "ajv": "^8.12.0",
"assert-never": "^1.2.1", "assert-never": "^1.2.1",
@@ -43,18 +40,19 @@
"jsontoxml": "^1.0.1", "jsontoxml": "^1.0.1",
"katex": "^0.16.9", "katex": "^0.16.9",
"mathjax": "^3.2.2", "mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15", "memoizee": "^0.4.15",
"next": "14.2.23", "next": "14.2.15",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nuqs": "^2.2.3", "nuqs": "^2.2.3",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"p-map": "^7.0.0", "p-map": "^7.0.0",
"parse-cache-control": "^1.0.1", "parse-cache-control": "^1.0.1",
"postcss-color-contrast": "^1.1.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0", "rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
@@ -62,7 +60,7 @@
"remark-rehype": "^11.1.0", "remark-rehype": "^11.1.0",
"rison": "^0.1.1", "rison": "^0.1.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shiki": "^1.27.2", "shiki": "^1.23.1",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2", "tailwind-shades": "^1.1.2",
"unified": "^11.0.4", "unified": "^11.0.4",
@@ -70,15 +68,13 @@
"usehooks-ts": "^3.1.0" "usehooks-ts": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@argos-ci/playwright": "^3.10.0", "@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "1.13.7", "@cloudflare/next-on-pages": "^1.13.5",
"vercel": "^39.3.0", "@cloudflare/workers-types": "^4.20240725.0",
"@cloudflare/workers-types": "^4.20241230.0", "@playwright/test": "^1.42.1",
"@playwright/test": "^1.49.1",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5", "@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20", "@types/node": "^20",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4", "@types/parse-cache-control": "^1.0.4",
+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; const dsn = process.env.SENTRY_DSN;
if (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, dsn,
release: process.env.SENTRY_RELEASE, integrations: [],
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited. enableTracing: false,
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
beforeSendTransaction: () => { beforeSendTransaction: () => {
return null; 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; const dsn = process.env.SENTRY_DSN;
if (dsn) { if (dsn) {
init({ init({
debug: false,
dsn, dsn,
release: process.env.SENTRY_RELEASE,
sampleRate: 0.1,
// Disable tracing as it creates additional requests in an env where subrequests are limited. // 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. // Disable transactions as it creates additional requests in an env where subrequests are limited.
// https://docs.sentry.io/platforms/node/configuration/filtering/#using--3 // 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 { import {
CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
SignatureVersion,
verifyImageSignature, verifyImageSignature,
} from '@/lib/image-signatures'; resizeImage,
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images'; CloudflareImageOptions,
imagesResizingSignVersion,
checkIsSizableImageURL,
} from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls'; import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge'; export const runtime = 'edge';
@@ -20,15 +20,12 @@ export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url'); let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign'); 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) { if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 }); 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); const url = parseImageAPIURL(urlParam);
// Check again if the image can be sized, even though we checked when rendering the Image component // 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 }); return new Response('Invalid url parameter', { status: 400 });
} }
// Verify the signature // For older signatures, we redirect to the url.
const verified = await verifyImageSignature(url, { signature, version: signatureVersion }); if (signatureVersion !== imagesResizingSignVersion) {
if (!verified) { return Response.redirect(url, 302);
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
} }
if (signatureVersion !== CURRENT_SIGNATURE_VERSION) { // Verify the signature
return NextResponse.redirect(url, 302); 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. // Cloudflare-specific options are in the cf object.
@@ -92,24 +90,6 @@ export async function GET(request: NextRequest) {
return response; return response;
} catch (error) { } catch (error) {
// Redirect to the original image if resizing fails // 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", () => { document.addEventListener("DOMContentLoaded", () => {
if (Array.from(document.scripts).find(script => script.src.includes('rocket-loader.min.js'))) { if (Array.from(document.scripts).find(script => script.src.includes('rocket-loader.min.js'))) {
const alert = document.createElement('div'); 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 = \` alert.innerHTML = \`
<strong>Error in site configuration:</strong> <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. 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.
@@ -5,7 +5,7 @@ import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
export default async function NotFound() { export default async function NotFound() {
const pointer = await getSiteContentPointer(); const pointer = getSiteContentPointer();
const [{ space }, { customization }] = await Promise.all([ const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey), getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer), getSiteData(pointer),
@@ -15,15 +15,7 @@ export default async function NotFound() {
return ( return (
<div <div
className={tcls( className={tcls('flex-1', 'flex', 'flex-row', 'items-center', 'justify-center', 'py-9')}
'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')}> <div className={tcls('max-w-80')}>
<h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}> <h2 className={tcls('text-2xl', 'font-semibold', 'mb-2')}>
@@ -5,7 +5,7 @@ import React from 'react';
import { PageAside } from '@/components/PageAside'; import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody'; 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 { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references'; import { ContentRefContext } from '@/lib/references';
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo'; import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
@@ -15,19 +15,15 @@ import { PageClientLayout } from './PageClientLayout';
import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch'; import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } from '../../fetch';
export const runtime = 'edge'; export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/** /**
* Fetch and render a page. * Fetch and render a page.
*/ */
export default async function Page(props: { export default async function Page(props: {
params: Promise<PagePathParams>; params: PagePathParams;
searchParams: Promise<{ fallback?: string }>; searchParams: { fallback?: string };
}) { }) {
const { params: rawParams, searchParams: rawSearchParams } = props; const { params, searchParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
const { const {
content: contentPointer, content: contentPointer,
@@ -53,12 +49,12 @@ export default async function Page(props: {
if (pathname !== rawPathname) { if (pathname !== rawPathname) {
// If the pathname was not normalized, redirect to the normalized version // If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again // before trying to resolve the page again
redirect(await getAbsoluteHref(pathname)); redirect(absoluteHref(pathname));
} else { } else {
notFound(); notFound();
} }
} else if (getPagePath(pages, page) !== rawPathname) { } else if (getPagePath(pages, page) !== rawPathname) {
redirect(await getPageHref(pages, page, linksContext)); redirect(pageHref(pages, page, linksContext));
} }
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None; 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} /> <PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
) : null} ) : null}
{/* We use a flex row reverse to render the aside first because the page is streamed. */} {/* 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"> <div className="flex flex-row-reverse justify-end">
<PageAside {page.layout.outline ? (
space={space} <PageAside
site={site} space={space}
customization={customization} site={site}
page={page} customization={customization}
document={document} page={page}
withHeaderOffset={headerOffset} document={document}
withFullPageCover={withFullPageCover} withHeaderOffset={headerOffset}
withPageFeedback={withPageFeedback} withFullPageCover={withFullPageCover}
context={contentRefContext} withPageFeedback={withPageFeedback}
/> context={contentRefContext}
/>
) : null}
<PageBody <PageBody
space={space} space={space}
pointer={contentPointer} pointer={contentPointer}
@@ -107,7 +105,10 @@ export default async function Page(props: {
page={page} page={page}
ancestors={ancestors} ancestors={ancestors}
document={document} document={document}
withPageFeedback={withPageFeedback} withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
/> />
</div> </div>
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
@@ -117,12 +118,8 @@ export default async function Page(props: {
); );
} }
export async function generateViewport({ export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
params, const { customization } = await fetchPageData(params);
}: {
params: Promise<PagePathParams>;
}): Promise<Viewport> {
const { customization } = await fetchPageData(await params);
return { return {
colorScheme: customization.themes.toggeable colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark ? customization.themes.default === CustomizationThemeMode.Dark
@@ -136,12 +133,12 @@ export async function generateMetadata({
params, params,
searchParams, searchParams,
}: { }: {
params: Promise<PagePathParams>; params: PagePathParams;
searchParams: Promise<{ fallback?: string }>; searchParams: { fallback?: string };
}): Promise<Metadata> { }): Promise<Metadata> {
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({ const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: await params, pagePathParams: params,
searchParams: await searchParams, searchParams,
}); });
if (!page) { if (!page) {
@@ -155,17 +152,16 @@ export async function generateMetadata({
description: page.description ?? '', description: page.description ?? '',
alternates: { alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior // 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).replace(/\/+$/, ''),
}, },
openGraph: { openGraph: {
images: [ images: [
customization.socialPreview.url ?? customization.socialPreview.url ??
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)), absoluteHref(`~gitbook/ogimage/${page.id}`, true),
], ],
}, },
robots: robots:
(await isSpaceIndexable({ space, site: site ?? null })) && isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
isPageIndexable(ancestors, page)
? 'index, follow' ? 'index, follow'
: 'noindex, nofollow', : 'noindex, nofollow',
}; };
@@ -189,7 +185,7 @@ async function getPageDataWithFallback(args: {
const rootPage = resolveFirstDocument(pages, []); const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) { if (redirectOnFallback && rootPage?.page) {
redirect(await getPageHref(pages, rootPage?.page)); redirect(pageHref(pages, rootPage?.page));
} }
page = rootPage?.page; page = rootPage?.page;
@@ -13,7 +13,7 @@ import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets'; import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build'; import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp'; import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { getAbsoluteHref, getBaseUrl } from '@/lib/links'; import { absoluteHref, baseUrl } from '@/lib/links';
import { isSpaceIndexable } from '@/lib/seo'; import { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils'; import { getContentTitle } from '@/lib/utils';
@@ -22,7 +22,6 @@ import { RocketLoaderDetector } from './RocketLoaderDetector';
import { fetchContentData } from '../fetch'; import { fetchContentData } from '../fetch';
export const runtime = 'edge'; export const runtime = 'edge';
export const dynamic = 'force-dynamic';
/** /**
* Layout when rendering the content. * Layout when rendering the content.
@@ -30,7 +29,7 @@ export const dynamic = 'force-dynamic';
export default async function ContentLayout(props: { children: React.ReactNode }) { export default async function ContentLayout(props: { children: React.ReactNode }) {
const { children } = props; const { children } = props;
const nonce = await getContentSecurityPolicyNonce(); const nonce = getContentSecurityPolicyNonce();
const { const {
content, content,
space, space,
@@ -44,8 +43,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
sections, sections,
} = await fetchContentData(); } = await fetchContentData();
const apiCtx = await api(); ReactDOM.preconnect(api().client.endpoint);
ReactDOM.preconnect(apiCtx.client.endpoint);
if (assetsDomain) { if (assetsDomain) {
ReactDOM.preconnect(assetsDomain); ReactDOM.preconnect(assetsDomain);
} }
@@ -57,14 +55,12 @@ export default async function ContentLayout(props: { children: React.ReactNode }
}); });
}); });
const queryStringTheme = await getQueryStringTheme();
return ( return (
<NuqsAdapter> <NuqsAdapter>
<ClientContexts <ClientContexts
nonce={nonce} nonce={nonce}
forcedTheme={ forcedTheme={
queryStringTheme ?? getQueryStringTheme() ??
(customization.themes.toggeable ? undefined : customization.themes.default) (customization.themes.toggeable ? undefined : customization.themes.default)
} }
> >
@@ -123,34 +119,34 @@ export async function generateMetadata(): Promise<Metadata> {
return { return {
title: getContentTitle(space, customization, site), title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`, generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(await getBaseUrl()), metadataBase: new URL(baseUrl()),
icons: { icons: {
icon: [ icon: [
{ {
url: url:
customIcon?.light ?? customIcon?.light ??
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)), absoluteHref('~gitbook/icon?size=small&theme=light', true),
type: 'image/png', type: 'image/png',
media: '(prefers-color-scheme: light)', media: '(prefers-color-scheme: light)',
}, },
{ {
url: url:
customIcon?.dark ?? customIcon?.dark ??
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)), absoluteHref('~gitbook/icon?size=small&theme=dark', true),
type: 'image/png', type: 'image/png',
media: '(prefers-color-scheme: dark)', 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). * For preview, the theme can be set via query string (?theme=light).
*/ */
async function getQueryStringTheme() { function getQueryStringTheme() {
const headersList = await headers(); const headersList = headers();
const queryStringTheme = headersList.get('x-gitbook-theme'); const queryStringTheme = headersList.get('x-gitbook-theme');
if (!queryStringTheme) { if (!queryStringTheme) {
return null; return null;
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { getSpace, getSite } from '@/lib/api'; import { getSpace, getSite } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links'; import { absoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo'; import { isSpaceIndexable } from '@/lib/seo';
@@ -11,7 +11,7 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space. * Generate a robots.txt for the current space.
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer(); const pointer = getSiteContentPointer();
const [site, space] = await Promise.all([ const [site, space] = await Promise.all([
getSite(pointer.organizationId, pointer.siteId), getSite(pointer.organizationId, pointer.siteId),
getSpace(pointer.spaceId, pointer.siteShareKey), getSpace(pointer.spaceId, pointer.siteShareKey),
@@ -20,8 +20,8 @@ export async function GET(req: NextRequest) {
const lines = [ const lines = [
`User-agent: *`, `User-agent: *`,
'Disallow: /~gitbook/', 'Disallow: /~gitbook/',
...((await isSpaceIndexable({ space, site })) ...(isSpaceIndexable({ space, site })
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`] ? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]), : [`Disallow: /`]),
]; ];
const content = lines.join('\n'); 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 options = getOptions(req.url);
const size = SIZES[options.size]; const size = SIZES[options.size];
const pointer = await getSiteContentPointer(); const pointer = getSiteContentPointer();
const spaceId = pointer.spaceId; const spaceId = pointer.spaceId;
const [space, { customization }] = await Promise.all([ const [space, { customization }] = await Promise.all([
@@ -48,7 +48,7 @@ export async function GET(req: NextRequest) {
return new ImageResponse( return new ImageResponse(
( (
<div <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={{ style={{
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -62,7 +62,7 @@ export async function GET(req: NextRequest) {
size.textSize, size.textSize,
'font-bold', 'font-bold',
'tracking-tight', 'tracking-tight',
options.theme === 'light' ? 'text-black' : 'text-white', options.theme === 'light' ? 'text-gray-900' : 'text-white',
)} )}
> >
{'emoji' in customization.favicon {'emoji' in customization.favicon
@@ -2,100 +2,47 @@ import { CustomizationHeaderPreset } from '@gitbook/api';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import colorContrast from 'postcss-color-contrast/js';
import React from 'react'; import React from 'react';
import { googleFontsMap } from '@/fonts'; import { absoluteHref } from '@/lib/links';
import { colorContrast } from '@/lib/colors'; import { tcls } from '@/lib/tailwind';
import { getAbsoluteHref } from '@/lib/links';
import { filterOutNullable } from '@/lib/typescript';
import { getContentTitle } from '@/lib/utils'; import { getContentTitle } from '@/lib/utils';
import { PageIdParams, fetchPageData } from '../../../../fetch'; import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge'; 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. * Render the OpenGraph image for a space.
*/ */
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) { export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, site } = await fetchPageData(await params); const { space, page, customization, site } = await fetchPageData(params);
// If user configured a custom social preview, we redirect to it.
if (customization.socialPreview.url) { if (customization.socialPreview.url) {
// If user configured a custom social preview, we redirect to it.
redirect(customization.socialPreview.url); redirect(customization.socialPreview.url);
} }
// Compute all text to load only the necessary fonts // TODO: Support all fonts available in GitBook
const contentTitle = customization.header.logo // 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),
: getContentTitle(space, customization, site ?? null); // but this prop isn't (yet) exposed through `ImageResponse`.
const pageTitle = page const interRegular = await fetch(
? page.title.length > 64 new URL('../../../../../../fonts/Inter/Inter-Regular.ttf', import.meta.url),
? page.title.slice(0, 64) + '...' ).then((res) => res.arrayBuffer());
: page.title const interBold = await fetch(
: 'Not found'; new URL('../../../../../../fonts/Inter/Inter-Bold.ttf', import.meta.url),
const pageDescription = ).then((res) => res.arrayBuffer());
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);
const theme = customization.themes.default; const theme = customization.themes.default;
const useLightTheme = theme === 'light'; const useLightTheme = theme === 'light';
// We have no access to CSS variables, so we'll have to hardcode some values // 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 = { let colors = {
background: baseColors[theme], 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 body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
}; };
const [gridWhite, gridBlack] = await Promise.all([ const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true), const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
]);
let gridAsset = useLightTheme ? gridBlack : gridWhite; let gridAsset = useLightTheme ? gridBlack : gridWhite;
@@ -145,41 +90,55 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
break; break;
} }
const favicon = await (async () => { const favicon = function () {
if ('icon' in customization.favicon) if ('icon' in customization.favicon)
return ( return (
<img <img
src={customization.favicon.icon[theme]} src={customization.favicon.icon[theme]}
width={40} width={40}
height={40} height={40}
tw="mr-4" tw={tcls('mr-4')}
alt="Icon" alt="Icon"
/> />
); );
if ('emoji' in customization.favicon) if ('emoji' in customization.favicon)
return ( return (
<span tw="text-4xl mr-4"> <span tw={tcls('text-4xl', 'mr-4')}>
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))} {String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
</span> </span>
); );
const src = await getAbsoluteHref( return (
`~gitbook/icon?size=medium&theme=${customization.themes.default}`, <img
true, 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( return new ImageResponse(
( (
<div <div
tw={`justify-between p-20 relative w-full h-full flex flex-col bg-[${colors.background}] text-[${colors.body}]`} tw={tcls(
style={{ 'justify-between',
fontFamily, 'p-20',
}} 'relative',
'w-full',
'h-full',
'flex',
'flex-col',
`bg-[${colors.background}]`,
`text-[${colors.body}]`,
)}
> >
{/* Gradient */} {/* Gradient */}
<div <div
tw="absolute inset-0" tw={tcls('absolute', 'inset-0')}
style={{ style={{
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`, backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
opacity: 0.5, opacity: 0.5,
@@ -187,7 +146,11 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
></div> ></div>
{/* Grid */} {/* 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 */} {/* Logo */}
{customization.header.logo ? ( {customization.header.logo ? (
@@ -201,21 +164,39 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
} }
/> />
) : ( ) : (
<div tw="flex"> <div tw={tcls('flex')}>
{favicon} {favicon()}
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3> <h3 tw={tcls('text-4xl', 'my-0')}>
{getContentTitle(space, customization, site ?? null)}
</h3>
</div> </div>
)} )}
{/* Title and description */} {/* Title and description */}
<div tw="flex flex-col"> <div tw={tcls('flex', 'flex-col')}>
<h1 <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> </h1>
{pageDescription ? ( {page?.description && page?.title.length <= 64 ? (
<h2 tw="text-4xl mb-0 mt-8 w-[75%] font-normal">{pageDescription}</h2> <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} ) : null}
</div> </div>
</div> </div>
@@ -223,7 +204,20 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
{ {
width: 1200, width: 1200,
height: 630, 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'; 'use client';
import { captureException, withScope } from '@sentry/nextjs'; import { captureException } from '@sentry/nextjs';
import React from 'react'; import React from 'react';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -15,12 +15,7 @@ export default function ErrorPage(props: {
const language = useLanguage(); const language = useLanguage();
React.useEffect(() => { React.useEffect(() => {
withScope((scope) => { captureException(error);
if ('_componentStack' in error && error._componentStack) {
scope.setExtra('componentStack', error._componentStack);
}
captureException(error);
});
}, [error]); }, [error]);
return ( return (
@@ -3,10 +3,10 @@ import { redirect } from 'next/navigation';
import { import {
getRevisionPageByPath, getRevisionPageByPath,
getDocument,
getSpaceContentData, getSpaceContentData,
getSiteData, getSiteData,
getSiteRedirectBySource, getSiteRedirectBySource,
getPageDocument,
} from '@/lib/api'; } from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages'; import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer'; import { getSiteContentPointer } from '@/lib/pointer';
@@ -23,7 +23,7 @@ export interface PageIdParams {
* Fetch all the data needed to render the content layout. * Fetch all the data needed to render the content layout.
*/ */
export async function fetchContentData() { export async function fetchContentData() {
const content = await getSiteContentPointer(); const content = getSiteContentPointer();
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] = const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([ await Promise.all([
@@ -65,7 +65,9 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
shareKey: contentData.shareKey, shareKey: contentData.shareKey,
params, 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 { return {
...contentData, ...contentData,
@@ -9,7 +9,7 @@ import { getSiteContentPointer } from '@/lib/pointer';
export default async function SiteRootLayout(props: { children: React.ReactNode }) { export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props; const { children } = props;
const pointer = await getSiteContentPointer(); const pointer = getSiteContentPointer();
const { customization } = await getSiteData(pointer); const { customization } = await getSiteData(pointer);
return ( return (
@@ -133,7 +133,7 @@ export function PageControlButtons(props: {
'items-center', 'items-center',
'justify-center', 'justify-center',
'text-lg', 'text-lg',
'text-tint', 'text-dark/6',
'px-6', 'px-6',
'py-2', 'py-2',
'bg-slate-100', 'bg-slate-100',
@@ -12,10 +12,10 @@ import { getSiteOrSpacePointerForPDF } from './pointer';
export default async function PDFRootLayout(props: { children: React.ReactNode }) { export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const { children } = props; const { children } = props;
const pointer = await getSiteOrSpacePointerForPDF(); const pointer = getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer const { customization } = await ('siteId' in pointer
? getSiteData(pointer) ? getSiteData(pointer)
: getSpaceLayoutData()); : getSpaceLayoutData(pointer.spaceId));
return ( return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout> <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. * Fetch all the layout data about a space at once.
*/ */
async function getSpaceLayoutData() { async function getSpaceLayoutData(spaceId: string) {
const [{ customization }, scripts] = await Promise.all([ const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(), getSpaceCustomization(spaceId),
[] as SpaceIntegrationScript[], [] as SpaceIntegrationScript[],
]); ]);
@@ -4,7 +4,6 @@ import {
RevisionPageDocument, RevisionPageDocument,
RevisionPageGroup, RevisionPageGroup,
SiteCustomizationSettings, SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space, Space,
} from '@gitbook/api'; } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
@@ -12,24 +11,24 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import * as React from 'react'; import * as React from 'react';
import { DocumentView } from '@/components/DocumentView'; import { DocumentView } from '../../../../components/DocumentView';
import { TrademarkLink } from '@/components/TableOfContents/Trademark'; import { TrademarkLink } from '../../../../components/TableOfContents/Trademark';
import { PolymorphicComponentProp } from '@/components/utils/types'; import { PolymorphicComponentProp } from '../../../../components/utils/types';
import { getSpaceLanguage } from '@/intl/server'; import { getSpaceLanguage } from '../../../../intl/server';
import { tString } from '@/intl/translate'; import { tString } from '../../../../intl/translate';
import { import {
getDocument, getDocument,
getSpace, getSpace,
getSpaceCustomization, getSpaceCustomization,
getSpaceContentData, getSpaceContentData,
getSiteData, getSiteData,
getPageDocument, } from '../../../../lib/api';
} from '@/lib/api'; import { pagePDFContainerId, PageHrefContext, absoluteHref } from '../../../../lib/links';
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links'; import { resolvePageId } from '../../../../lib/pages';
import { resolvePageId } from '@/lib/pages'; import { ContentRefContext, resolveContentRef } from '../../../../lib/references';
import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { tcls } from '../../../../lib/tailwind';
import { tcls } from '@/lib/tailwind'; import { SiteContentPointer, SpaceContentPointer } from '../../../../lib/api';
import { PDFSearchParams, getPDFSearchParams } from '@/lib/urls'; import { PDFSearchParams, getPDFSearchParams } from '../../../../lib/urls';
import './pdf.css'; import './pdf.css';
import { PageControlButtons } from './PageControlButtons'; import { PageControlButtons } from './PageControlButtons';
@@ -39,13 +38,12 @@ import { PrintButton } from './PrintButton';
const DEFAULT_LIMIT = 100; const DEFAULT_LIMIT = 100;
export const runtime = 'edge'; export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const pointer = await getSiteOrSpacePointerForPDF(); const pointer = getSiteOrSpacePointerForPDF();
const [space, { customization }] = await Promise.all([ const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined), getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(), 'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
]); ]);
return { return {
@@ -57,21 +55,21 @@ export async function generateMetadata(): Promise<Metadata> {
/** /**
* Render a space as a standalone HTML page that can be printed as a PDF. * Render a space as a standalone HTML page that can be printed as a PDF.
*/ */
export default async function PDFHTMLOutput(props: { export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
searchParams: Promise<{ [key: string]: string }>; const pointer = {"siteId": "site_dIY9z", "spaceId": "-MdwVZ6HOZriajCf5nXH", "siteSpaceId": "sitesp_mX2p5", "organizationId": "-M4tdxG8qotLgGZnLpFR"} as SiteContentPointer | SpaceContentPointer;
}) {
const pointer = await getSiteOrSpacePointerForPDF(); console.log('vib pointer', JSON.stringify(pointer));
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams)); const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params // 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(); currentPDFUrl += '?' + searchParams.toString();
// Load the content, // Load the content,
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([ 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), getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]); ]);
const language = getSpaceLanguage(customization); const language = getSpaceLanguage(customization);
@@ -79,7 +77,7 @@ export default async function PDFHTMLOutput(props: {
// Compute the pages to render // Compute the pages to render
const { pages, total } = selectPages(rootPages, pdfParams); const { pages, total } = selectPages(rootPages, pdfParams);
const pageIds = pages.map( const pageIds = pages.map(
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string], ({ page }) => [page.id, pagePDFContainerId(page)] as [string, string],
); );
const linksContext: PageHrefContext = { const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id), pdf: pages.map(({ page }) => page.id),
@@ -91,16 +89,17 @@ export default async function PDFHTMLOutput(props: {
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}> <div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a <a
title={tString(language, 'pdf_goback')} title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? (await getAbsoluteHref(''))} href={pdfParams.back ?? absoluteHref('')}
className={tcls( className={tcls(
'flex', 'flex',
'flex-row', 'flex-row',
'items-center', 'items-center',
'justify-center', 'justify-center',
'text-sm', 'text-sm',
'text-tint', 'text-dark/6',
'hover:text-primary', 'hover:text-primary',
'p-4', 'p-4',
'dark:text-light/5',
'rounded-full', 'rounded-full',
'bg-white', 'bg-white',
'shadow-sm', 'shadow-sm',
@@ -123,9 +122,10 @@ export default async function PDFHTMLOutput(props: {
'items-center', 'items-center',
'justify-center', 'justify-center',
'text-sm', 'text-sm',
'text-tint', 'text-dark/6',
'hover:text-primary', 'hover:text-primary',
'p-4', 'p-4',
'dark:text-light/5',
'rounded-full', 'rounded-full',
'bg-white', 'bg-white',
'shadow-sm', 'shadow-sm',
@@ -146,11 +146,7 @@ export default async function PDFHTMLOutput(props: {
limit={pdfParams.limit ?? DEFAULT_LIMIT} limit={pdfParams.limit ?? DEFAULT_LIMIT}
trademark={ trademark={
customization.trademark.enabled ? ( customization.trademark.enabled ? (
<TrademarkLink <TrademarkLink space={space} customization={customization} />
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Pdf}
/>
) : null ) : null
} }
/> />
@@ -163,7 +159,7 @@ export default async function PDFHTMLOutput(props: {
<React.Suspense <React.Suspense
key={page.id} key={page.id}
fallback={ fallback={
<PrintPage id={getPagePDFContainerId(page)}> <PrintPage id={pagePDFContainerId(page)}>
<p>Loading...</p> <p>Loading...</p>
</PrintPage> </PrintPage>
} }
@@ -208,7 +204,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props; const { page } = props;
return ( return (
<PrintPage id={getPagePDFContainerId(page)}> <PrintPage id={pagePDFContainerId(page)}>
<div <div
className={tcls( className={tcls(
'break-before-page', 'break-before-page',
@@ -232,10 +228,11 @@ async function PDFPageDocument(props: {
refContext: ContentRefContext; refContext: ContentRefContext;
}) { }) {
const { space, page, refContext } = props; const { space, page, refContext } = props;
const document = await getPageDocument(space.id, page);
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
return ( return (
<PrintPage id={getPagePDFContainerId(page)}> <PrintPage id={pagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1> <h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
{page.description ? ( {page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p> <p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
@@ -254,7 +251,7 @@ async function PDFPageDocument(props: {
}, },
contentRefContext: refContext, contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext), resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => getPagePDFContainerId(page, id), getId: (id) => pagePDFContainerId(page, id),
}} }}
/> />
) : null} ) : null}
@@ -8,11 +8,9 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
* *
* This function returns the pointer depending on the context. * This function returns the pointer depending on the context.
*/ */
export async function getSiteOrSpacePointerForPDF(): Promise< export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
SiteContentPointer | SpaceContentPointer
> {
try { try {
return await getSiteContentPointer(); return getSiteContentPointer();
} catch (error) { } catch (error) {
return getSpacePointer(); return getSpacePointer();
} }
@@ -0,0 +1,27 @@
const { renderToPipeableStream } = require('react-dom/server');
const PDFHTMLOutput = require('./page');
//import { renderToPipeableStream, renderToStaticNodeStream } from 'react-dom/server';
//import PDFHTMLOutput from './page';
//import fs from 'fs';
//import React from 'react';
const fs = require('fs');
const React = require('react');
// Create a writable stream to save the HTML to a file
const writeStream = fs.createWriteStream('output.html');
// Start the server-side rendering with renderToPipeableStream
const { pipe } = renderToPipeableStream(React.createElement(PDFHTMLOutput, {searchParams : {'a': 'b'}}) as any);
// Pipe the render stream to the writable file stream
pipe(writeStream);
// Optionally, you can handle errors and closure
writeStream.on('finish', () => {
console.log('HTML has been written to output.html');
});
writeStream.on('error', (err: any) => {
console.error('Error writinsg to file:', err);
});
@@ -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 { Space } from '@gitbook/api';
import { Icon } from '@gitbook/icons'; import { Icon } from '@gitbook/icons';
import { headers } from 'next/headers';
import React from 'react'; import React from 'react';
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api'; import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
@@ -15,7 +14,33 @@ interface AdminToolbarProps {
space: Space; 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 ( return (
<div <div
className={tcls( className={tcls(
@@ -26,88 +51,51 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
'transform', 'transform',
'-translate-x-1/2', '-translate-x-1/2',
'rounded-full', 'rounded-full',
'bg-dark-1/9',
'bg-tint-12/9',
'dark:bg-tint-1/9',
'shadow-lg', 'shadow-lg',
'min-h-10', 'min-h-10',
'min-w-40', 'min-w-40',
'p-2', 'p-2',
'max-w-md', 'max-w-md',
'border-tint-12/1', 'border-dark-1',
'backdrop-blur-md', 'backdrop-blur-sm',
)} )}
> >
<React.Suspense fallback={null}>{props.children}</React.Suspense> <React.Suspense fallback={null}>{toolbar}</React.Suspense>
</div> </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 }) { async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
const { spaceId, changeRequestId } = props; const { spaceId, changeRequestId } = props;
const changeRequest = await getChangeRequest(spaceId, changeRequestId); const changeRequest = await getChangeRequest(spaceId, changeRequestId);
if (!changeRequest) {
return null;
}
return ( 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}> <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> </ToolbarButton>
<ToolbarBody> <RefreshChangeRequestButton
<p> spaceId={spaceId}
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'} changeRequestId={changeRequestId}
</p> revisionId={changeRequest.revision}
<p className="text-xs text-tint-2 dark:text-tint-11"> updatedAt={new Date(changeRequest.updatedAt).getTime()}
Change request updated <DateRelative value={changeRequest.updatedAt} /> />
</p> </ToolbarButtonGroups>
</ToolbarBody> </Toolbar>
<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>
); );
} }
@@ -119,35 +107,31 @@ async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
}); });
return ( 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}> <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> </ToolbarButton>
<ToolbarBody> {revision.git?.url ? (
<p> <ToolbarButton title="Open git commit" href={revision.git.url}>
Revision created <DateRelative value={revision.createdAt} /> <Icon
</p> icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
{revision.git ? ( className="size-4"
<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" />
</ToolbarButton> </ToolbarButton>
{revision.git?.url ? ( ) : null}
<ToolbarButton title="Open git commit" href={revision.git.url}> </ToolbarButtonGroups>
<Icon </Toolbar>
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
); );
} }
@@ -19,8 +19,8 @@ export function Toolbar(props: { children: React.ReactNode }) {
'py-1', 'py-1',
'rounded-full', 'rounded-full',
'truncate', 'truncate',
'text-tint-1', 'text-light',
'dark:text-tint-12', 'dark:text-light',
)} )}
> >
{children} {children}
@@ -50,11 +50,12 @@ export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
'gap-1', 'gap-1',
'text-sm', 'text-sm',
'rounded-full', 'rounded-full',
'hover:bg-dark-1',
'hover:text-white',
'truncate', 'truncate',
'text-tint-1', // Equal to text-tint-strong in dark mode 'text-light',
'dark:text-tint-12', // Equal to text-tint-strong in dark mode 'dark:text-light',
'hover:bg-tint-12', 'dark:hover:bg-dark-2',
'dark:hover:bg-tint-1',
'hover:shadow-lg', 'hover:shadow-lg',
'cursor-pointer', 'cursor-pointer',
)} )}
+41 -38
View File
@@ -1,21 +1,12 @@
'use client'; 'use client';
import { import { SiteAds, SiteAdsStatus } from '@gitbook/api';
SiteAds,
SiteAdsStatus,
SiteInsightsAdPlacement,
SiteInsightsAd,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import * as React from 'react'; import * as React from 'react';
import { t, useLanguage } from '@/intl/client'; import { t, useLanguage } from '@/intl/client';
import { ClassValue, tcls } from '@/lib/tailwind'; import { ClassValue, tcls } from '@/lib/tailwind';
import { renderAd } from './renderAd'; 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. * Zone ID provided by BuySellAds for the preview.
@@ -37,34 +28,46 @@ export function Ad({
}: { }: {
zoneId: string | null; zoneId: string | null;
spaceId: string; spaceId: string;
placement: SiteInsightsAdPlacement; placement: string;
ignore: boolean; ignore: boolean;
style?: ClassValue; style?: ClassValue;
siteAdsStatus?: SiteAds['status']; siteAdsStatus?: SiteAds['status'];
mode?: 'classic' | 'auto' | 'cover'; mode?: 'classic' | 'auto' | 'cover';
}) { }) {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [ad, setAd] = React.useState< const [visible, setVisible] = React.useState(false);
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
>(undefined);
const trackEvent = useTrackEvent();
// Track display of the ad // Observe the container visibility
React.useEffect(() => { React.useEffect(() => {
if (ad?.insightsAd) { if (!containerRef.current) {
trackEvent({ return;
type: 'ad_display',
ad: ad.insightsAd,
});
} }
}, [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, // When the container is visible,
// track an impression on the ad and fetch it // track an impression on the ad and fetch it
React.useEffect(() => { React.useEffect(() => {
if (!hasBeenInViewport) { if (!visible) {
return; return;
} }
@@ -108,13 +111,13 @@ export function Ad({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [hasBeenInViewport, zoneId, ignore, placement, mode, siteAdsStatus]); }, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
return ( return (
<div ref={containerRef} className={tcls(style)} data-visual-test="removed"> <div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? ( {ad ? (
<> <>
{ad.children} {ad}
<AdSponsoredLink spaceId={spaceId} /> <AdSponsoredLink spaceId={spaceId} />
</> </>
) : null} ) : null}
@@ -125,7 +128,6 @@ export function Ad({
function AdSponsoredLink(props: { spaceId: string }) { function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props; const { spaceId } = props;
const language = useLanguage(); const language = useLanguage();
const trackEvent = useTrackEvent();
const viaUrl = new URL('https://www.gitbook.com'); const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content'); viaUrl.searchParams.set('utm_source', 'content');
@@ -133,18 +135,19 @@ function AdSponsoredLink(props: { spaceId: string }) {
viaUrl.searchParams.set('utm_campaign', spaceId); viaUrl.searchParams.set('utm_campaign', spaceId);
return ( return (
<p className={tcls('mt-2', 'mr-2', 'text-xs', 'text-right', 'text-tint-subtle')}> <p
<Link className={tcls(
target="_blank" 'mt-2',
href={viaUrl.toString()} 'mr-2',
className={tcls('hover:underline')} 'text-xs',
insights={{ 'text-right',
type: 'trademark_click', 'text-dark/5',
placement: SiteInsightsTrademarkPlacement.Ad, 'dark:text-light/5',
}} )}
> >
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
{t(language, 'sponsored_via_gitbook')} {t(language, 'sponsored_via_gitbook')}
</Link> </a>
</p> </p>
); );
} }
@@ -1,65 +1,55 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react'; import * as React from 'react';
import { getResizedImageURL } from '@/lib/images'; import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AdItem } from './types'; import { AdItem } from './types';
import { Link } from '../primitives';
/** /**
* Classic rendering for an ad. * Classic rendering for an ad.
*/ */
export async function AdClassicRendering({ export function AdClassicRendering({ ad }: { ad: AdItem }) {
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;
return ( return (
<Link <a
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls( className={tcls(
'flex', 'flex',
'flex-col', 'flex-col',
'gap-4', 'gap-4',
'bg-tint', 'bg-light-2',
'text-tint', 'text-dark/7',
'hover:text-tint-strong', 'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg', 'rounded-lg',
'p-4', 'p-4',
)} )}
href={ad.statlink} href={ad.statlink}
rel="sponsored noopener"
target="_blank"
> >
{smallImgSrc && 'smallImage' in ad ? ( {'smallImage' in ad ? (
<div> <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> </div>
) : logoSrc && 'logo' in ad ? ( ) : (
<div <div
className={tcls('px-6', 'py-4', 'rounded-md')} className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }} style={{ backgroundColor: ad.backgroundColor }}
> >
<img alt="Ads logo" src={logoSrc} /> <img
alt="Ads logo"
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
</div> </div>
) : null} )}
<div className={tcls('flex', 'flex-col')}> <div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div> <div className={tcls('text-xs')}>{ad.description}</div>
</div> </div>
</Link> </a>
); );
} }
@@ -1,4 +1,3 @@
import { SiteInsightsAd } from '@gitbook/api';
import * as React from 'react'; import * as React from 'react';
import { hexToRgba } from '@/lib/colors'; import { hexToRgba } from '@/lib/colors';
@@ -6,41 +5,27 @@ import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { AdCover } from './types'; import { AdCover } from './types';
import { Link } from '../primitives';
/** /**
* Cover rendering for an ad. * Cover rendering for an ad.
*/ */
export async function AdCoverRendering({ export function AdCoverRendering({ ad }: { ad: AdCover }) {
ad, const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
insightsAd,
}: {
ad: AdCover;
insightsAd: SiteInsightsAd | null;
}) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return ( return (
<Link <a
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
className={tcls( className={tcls(
'group/ad', 'group/ad',
'relative', 'relative',
'flex', 'flex',
'flex-col', 'flex-col',
'gap-4', 'gap-4',
'bg-tint-subtle', 'bg-light-2',
'text-tint', 'text-dark/7',
'hover:text-tint-strong', 'dark:bg-dark-2',
'dark:text-light/7',
'hover:text-dark/9',
'dark:hover:text-light/9',
'rounded-lg', 'rounded-lg',
'p-4', 'p-4',
'overflow-hidden', 'overflow-hidden',
@@ -48,6 +33,8 @@ export async function AdCoverRendering({
)} )}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }} style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink} href={ad.statlink}
rel="sponsored noopener"
target="_blank"
> >
<div <div
className={tcls( className={tcls(
@@ -119,6 +106,6 @@ export async function AdCoverRendering({
backgroundColor: hexToRgba(ad.backgroundColor, 0.8), backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}} }}
/> />
</Link> </a>
); );
} }
@@ -1,6 +1,5 @@
'use server'; 'use server';
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { AdClassicRendering } from './AdClassicRendering'; import { AdClassicRendering } from './AdClassicRendering';
@@ -21,7 +20,7 @@ interface FetchLiveAdOptions {
/** Mode to render the Ad */ /** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover'; mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */ /** Name of the placement for the ad */
placement: SiteInsightsAdPlacement; placement: string;
/** If true, we'll not track it as an impression */ /** If true, we'll not track it as an impression */
ignore: boolean; ignore: boolean;
} }
@@ -41,35 +40,23 @@ interface FetchPlaceholderAdOptions {
export async function renderAd(options: FetchAdOptions) { export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic'; 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) { if (!result || !result.ad.description || !result.ad.statlink) {
return null; return null;
} }
const { ad } = result; const { ad } = result;
const insightsAd: SiteInsightsAd | null = return (
options.source === 'live' <>
? { {mode === 'classic' || !('callToAction' in ad) ? (
placement: options.placement, <AdClassicRendering ad={ad} />
zoneId: options.zoneId, ) : (
domain: 'company' in ad ? ad.company : '', <AdCoverRendering ad={ad} />
} )}
: null; {ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : 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,
};
} }
async function fetchAd({ async function fetchAd({
@@ -77,7 +64,7 @@ async function fetchAd({
placement, placement,
ignore, ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> { }: 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`); const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`); url.searchParams.set('segment', `placement:${placement}`);
@@ -99,8 +86,8 @@ async function fetchAd({
return null; return null;
} }
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> { function getPlaceholderAd(): { ad: AdItem; ip: string } {
const { ip } = await getUserAgentAndIp(); const { ip } = getUserAgentAndIp();
return { return {
ad: { ad: {
@@ -132,8 +119,8 @@ async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
}; };
} }
async function getUserAgentAndIp() { function getUserAgentAndIp() {
const headersSet = await headers(); const headersSet = headers();
const ip = const ip =
headersSet.get('x-gitbook-ipv4') ?? headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ?? headersSet.get('x-gitbook-ip') ??
@@ -11,8 +11,5 @@ export async function hasContentBeenUpdated(props: {
revisionId: string; revisionId: string;
}) { }) {
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId); const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
if (!changeRequest) {
return false;
}
return changeRequest.revision !== props.revisionId; return changeRequest.revision !== props.revisionId;
} }
@@ -44,11 +44,11 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
className={tcls( className={tcls(
'fixed', 'fixed',
'z-10', 'z-10',
'bg-tint-base', 'bg-white',
'rounded', 'rounded',
'straight-corners:rounded-none', 'straight-corners:rounded-none',
'ring-1', 'ring-1',
'ring-tint-subtle', 'ring-dark/2',
'shadow-1xs', 'shadow-1xs',
'p-4', 'p-4',
'pr-8', 'pr-8',
@@ -58,6 +58,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'max-w-md', 'max-w-md',
'text-balance', 'text-balance',
'sm:left-auto', 'sm:left-auto',
'dark:ring-light/2',
'dark:bg-dark',
)} )}
> >
<p id={describedById} className={tcls('text-sm')}> <p id={describedById} className={tcls('text-sm')}>
@@ -66,13 +68,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'cookies_prompt', 'cookies_prompt',
<a <a
href={privacyPolicy} href={privacyPolicy}
className={tcls( className={tcls('text-primary-500', 'hover:text-primary-600', 'underline')}
'text-primary-subtle',
'hover:text-primary',
'contrast-more:text-primary',
'contrast-more:hover:text-primary-strong',
'underline',
)}
> >
{t(language, 'cookies_prompt_privacy')} {t(language, 'cookies_prompt_privacy')}
</a>, </a>,
@@ -92,7 +88,8 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
'justify-center', 'justify-center',
'items-center', 'items-center',
'rounded-sm', 'rounded-sm',
'hover:bg-tint-hover', 'hover:bg-dark/2',
'dark:hover:bg-light/1',
)} )}
title={tString(language, 'cookies_close')} title={tString(language, 'cookies_close')}
> >
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
import { InlineProps } from '../Inline'; import { InlineProps } from '../Inline';
import { Inlines } from '../Inlines'; import { Inlines } from '../Inlines';
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) { export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
const { inline, context, document, children } = props; const { inline, context, document, children } = props;
const fragment = getNodeFragmentByType(inline, 'annotation-body'); const fragment = getNodeFragmentByType(inline, 'annotation-body');
@@ -31,32 +31,38 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
className={tcls( className={tcls(
'text-sm', 'text-sm',
'max-w-[280px]', 'max-w-[280px]',
'bg-tint', 'bg-light',
'ring-1', 'ring-1',
'ring-tint', 'ring-dark/2',
'rounded', 'rounded',
'shadow-1xs', 'shadow-1xs',
'shadow-tint-12/1', 'shadow-dark/1',
'dark:shadow-tint-1/2',
'p-3', 'p-3',
'[&_p]:leading-snug', '[&_p]:leading-snug',
'dark:bg-dark',
'dark:ring-light/2',
'dark:shadow-dark/4',
'-outline-offset-2', '-outline-offset-2',
'outline-2', 'outline-2',
'outline-primary/8', 'outline-primary/8',
'z-20', 'z-20',
)} )}
sideOffset={4} sideOffset={5}
> >
{body} {body}
<Popover.Arrow asChild> <Popover.Arrow asChild>
<svg <svg
width="100%"
viewBox="0 0 8 5" viewBox="0 0 8 5"
preserveAspectRatio="xMaxYMid meet"
className={tcls( className={tcls(
'relative', 'relative',
'z-[2]', 'z-[2]',
'fill-tint-3', // Same as bg-tint 'fill-light',
'stroke-tint-7', // Same as ring-tint 'stroke-dark/2',
'[paint-order:stroke_fill]', '[paint-order:stroke_fill]',
'dark:fill-dark',
'dark:stroke-light/2',
)} )}
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" 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 { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references'; import { ResolvedContentRef } from '@/lib/references';
@@ -12,7 +12,7 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
const resolved = await context.resolveContentRef(block.data.ref, { const resolved = await context.resolveContentRef(block.data.ref, {
resolveAnchorText: true, resolveAnchorText: true,
iconStyle: ['text-xl', 'text-tint'], iconStyle: ['text-xl', 'text-dark/6', 'dark:text-light/6'],
}); });
if (!resolved) { if (!resolved) {
@@ -35,11 +35,8 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
title={resolved.text} title={resolved.text}
style={style} style={style}
insights={{ insights={{
type: 'link_click', target: block.data.ref,
link: { position: 'content',
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
}} }}
/> />
); );
@@ -55,7 +52,7 @@ async function SpaceRefCard(
return null; return null;
} }
const { customization: spaceCustomization } = await getSpaceCustomization(); const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
const customFavicon = spaceCustomization?.favicon; const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined; const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined; const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
@@ -23,7 +23,6 @@ export function Caption(
fit?: boolean; fit?: boolean;
wrapperStyle?: ClassValue; wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile; block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
} & DocumentContextProps, } & DocumentContextProps,
) { ) {
const { const {
@@ -32,18 +31,22 @@ export function Caption(
block, block,
context, context,
fit = false, fit = false,
withBorder = false,
wrapperStyle = [ wrapperStyle = [
'relative', 'relative',
'overflow-hidden', 'overflow-hidden',
'rounded',
'straight-corners:rounded-none',
'after:block', 'after:block',
'after:absolute', 'after:absolute',
'after:-inset-[0]', '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', 'after:pointer-events-none',
fit ? 'w-fit' : null, 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, style,
} = props; } = props;
@@ -62,7 +65,15 @@ export function Caption(
return ( return (
<picture className={tcls('relative', style)}> <picture className={tcls('relative', style)}>
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div> <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 <Inlines
nodes={captionParagraph.nodes} nodes={captionParagraph.nodes}
document={document} document={document}
@@ -1,56 +0,0 @@
'use client';
import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useRef, useState } from 'react';
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
import type { HighlightLine, RenderedInline } from './highlight';
import type { BlockProps } from '../Block';
import { CodeBlockRenderer } from './CodeBlockRenderer';
import { plainHighlight } from './plain-highlight';
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
inlines: RenderedInline[];
};
/**
* Render a code-block client-side by loading the highlighter asynchronously.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
*/
export function ClientCodeBlock(props: ClientBlockProps) {
const { block, style, inlines } = props;
const blockRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block, []));
// Preload the highlighter when the block is mounted.
useEffect(() => {
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
}, [block]);
// Check if the block is in the viewport to start highlighting it.
const hasBeenInViewport = useHasBeenInViewport(blockRef, {
rootMargin: '200px',
});
// Highlight the block when it's in the viewport.
useEffect(() => {
if (hasBeenInViewport) {
let canceled = false;
import('./highlight').then(({ highlight }) => {
// We use requestIdleCallback to avoid blocking the main thread
// when scrolling.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => highlight(block, inlines).then(setLines));
} else {
highlight(block, inlines).then(setLines);
}
});
return () => {
canceled = true;
};
}
}, [hasBeenInViewport, block, inlines]);
return <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines} />;
}
@@ -1,38 +1,291 @@
import type { DocumentBlockCode } from '@gitbook/api'; import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
import { getNodeFragmentByType } from '@/lib/document'; import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
import { BlockProps } from '../Block'; import { BlockProps } from '../Block';
import { ClientCodeBlock } from './ClientCodeBlock'; import { DocumentContext } from '../DocumentView';
import { getInlines, RenderedInline } from './highlight'; import { Inline } from '../Inline';
import { Blocks } from '../Blocks';
import './theme.css';
/** /**
* Render a code block, can be client-side or server-side. * Render an entire code-block. The syntax highlighting is done server-side.
*/ */
export function CodeBlock(props: BlockProps<DocumentBlockCode>) { export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
const { block, document, style, context } = props; const { block, document, style, context } = props;
const inlines = getInlines(block); const lines = await highlight(block);
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
const body = (() => {
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
if (!fragment) {
return null;
}
return (
<Blocks
key={index}
document={document}
ancestorBlocks={[]}
context={context}
nodes={fragment.nodes}
style="space-y-4"
/>
);
})();
return { inline, body }; const id = block.key!;
});
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />; const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const titleRoundingStyle = [
'rounded-md',
'straight-corners:rounded-sm',
title ? 'rounded-ss-none' : null,
];
return (
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
<div
className={tcls(
'flex',
'items-center',
'justify-start',
'[grid-area:1/1]',
'text-sm',
'gap-2',
)}
>
{title ? (
<div
className={tcls(
'text-xs',
'tracking-wide',
'text-dark/7',
'leading-none',
'inline-flex',
'items-center',
'justify-center',
'bg-light-2',
'rounded-t',
'straight-corners:rounded-t-s',
'px-3',
'py-2',
'dark:bg-dark-2',
'dark:text-light/7',
)}
>
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style={[
'group-hover/codeblock:opacity-[1]',
'transition-opacity',
'duration-75',
'opacity-0',
'text-xs',
'[grid-area:2/1]',
'z-[2]',
'justify-self-end',
'backdrop-blur-md',
'leading-none',
'self-start',
'ring-1',
'ring-dark/2',
'text-dark/7',
'bg-transparent',
'rounded-md',
'mr-2',
'mt-2',
'p-1',
'hover:ring-dark/3',
'dark:ring-light/2',
'dark:text-light/7',
'dark:hover:ring-light/3',
]}
/>
<pre
className={tcls(
'[grid-area:2/1]',
'relative',
'overflow-auto',
'bg-light-2',
'dark:bg-dark-2',
'border-light-4',
'dark:border-dark-4',
'hide-scroll',
titleRoundingStyle,
)}
>
<code
id={id}
className={tcls(
'min-w-full',
'inline-grid',
'[grid-template-columns:auto_1fr]',
'py-2',
'px-2',
'[counter-reset:line]',
withWrap ? 'whitespace-pre-wrap' : '',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
block={block}
document={document}
key={index}
line={line}
lineIndex={index + 1}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
withWrap={withWrap}
context={context}
/>
))}
</code>
</pre>
</div>
);
}
function CodeHighlightLine(props: {
block: DocumentBlockCode;
document: JSONDocument;
line: HighlightLine;
lineIndex: number;
isLast: boolean;
withLineNumbers: boolean;
withWrap: boolean;
context: DocumentContext;
}) {
const { block, document, line, isLast, withLineNumbers, context } = props;
return (
<span
className={tcls(
'grid',
'[grid-template-columns:subgrid]',
'col-span-2',
'relative',
'ring-1',
'ring-transparent',
'hover:ring-dark-4/5',
'hover:z-[1]',
'dark:hover:ring-light-4/4',
'rounded',
//first child
'[&.highlighted:first-child]:rounded-t-md',
'[&.highlighted:first-child>*]:mt-1',
//last child
'[&.highlighted:last-child]:rounded-b-md',
'[&.highlighted:last-child>*]:mb-1',
//is only child, dont hover effect line
'[&:only-child]:hover:ring-transparent',
//select all highlighted
'[&.highlighted]:rounded-none',
//select first in group
'[&:not(.highlighted)_+_.highlighted]:rounded-t-md',
'[&:not(.highlighted)_+_.highlighted>*]:mt-1',
//select last in group
'[&.highlighted:has(+:not(.highlighted))]:rounded-b-md',
'[&.highlighted:has(+:not(.highlighted))>*]:mb-1',
//select if highlight is singular in group
'[&:not(.highlighted)_+_.highlighted:has(+:not(.highlighted))]:rounded-md',
line.highlighted ? ['highlighted', 'bg-light-3', 'dark:bg-dark-3'] : null,
)}
>
{withLineNumbers ? (
<span
className={tcls(
'text-sm',
'text-right',
'pr-3.5',
'rounded-l',
'pl-2',
'sticky',
'left-[-3px]',
'bg-gradient-to-r',
'from-80%',
'from-light-2',
'to-transparent',
'dark:from-dark-2',
'dark:to-transparent',
withLineNumbers
? [
'before:text-dark/5',
'before:content-[counter(line)]',
'[counter-increment:line]',
'dark:before:text-light/4',
line.highlighted
? [
'before:text-dark/6',
'dark:before:text-light/8',
'bg-gradient-to-r',
'from-80%',
'from-light-3',
'to-transparent',
'dark:from-dark-3',
'dark:to-transparent',
]
: null,
]
: [],
)}
></span>
) : null}
<span className={tcls('ml-3', 'block', 'text-sm')}>
<CodeHighlightTokens tokens={line.tokens} document={document} context={context} />
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
<span className="ew">{'\u200B'}</span>
) : (
'\n'
)}
</span>
</span>
);
}
function CodeHighlightTokens(props: {
tokens: HighlightToken[];
document: JSONDocument;
context: DocumentContext;
}) {
const { tokens, document, context } = props;
return (
<>
{tokens.map((token, index) => (
<CodeHighlightToken
key={index}
token={token}
document={document}
context={context}
/>
))}
</>
);
}
function CodeHighlightToken(props: {
token: HighlightToken;
document: JSONDocument;
context: DocumentContext;
}) {
const { token, document, context } = props;
if (token.type === 'inline') {
return (
<Inline
inline={token.inline}
document={document}
context={context}
ancestorInlines={[]}
>
<CodeHighlightTokens
tokens={token.children}
document={document}
context={context}
/>
</Inline>
);
}
if (token.type === 'plain') {
return <>{token.content}</>;
}
// if (!token.token.color) {
// return <>{token.token.content}</>;
// }
// return <span style={{ color: token.token.color }}>{token.token.content}</span>;
} }
@@ -1,37 +0,0 @@
.highlight-line {
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-tint-hover hover:z-[1] rounded;
@apply only:hover:ring-transparent;
@apply [counter-increment:line];
&.highlighted {
@apply bg-tint-active;
@apply first:rounded-t-md *:first:mt-1;
@apply last:rounded-b-md *:last:mb-1;
@apply rounded-none;
}
&:not(.highlighted) + .highlighted {
@apply rounded-t-md *:mt-1;
}
&.highlighted:has(+ :not(.highlighted)) {
@apply rounded-b-md *:mb-1;
}
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
@apply rounded-md;
}
}
.highlight-line-number {
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-tint to-transparent;
@apply before:text-tint before:content-[counter(line)];
.highlight-line.highlighted > & {
@apply before:text-tint bg-gradient-to-r from-80% from-tint-active to-transparent;
}
}
.highlight-line-content {
@apply ml-3 block text-sm;
}
@@ -1,120 +0,0 @@
import type { DocumentBlockCode } from '@gitbook/api';
import assertNever from 'assert-never';
import { forwardRef, useId } from 'react';
import { tcls } from '@/lib/tailwind';
import { CopyCodeButton } from './CopyCodeButton';
import type { HighlightLine, HighlightToken } from './highlight';
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
import type { BlockProps } from '../Block';
import './theme.css';
import './CodeBlockRenderer.css';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
};
/**
* The logic of rendering a code block from lines.
*/
export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
props: CodeBlockRendererProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
const { block, style, lines } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
return (
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
<div className="flex items-center justify-start [grid-area:1/1] text-sm gap-2">
{title ? (
<div className="text-xs tracking-wide text-tint leading-none inline-flex items-center justify-center bg-tint rounded-t straight-corners:rounded-t-s px-3 py-2">
{title}
</div>
) : null}
</div>
<CopyCodeButton
codeId={id}
style="group-hover/codeblock:opacity-[1] transition-opacity duration-75 opacity-0 text-xs [grid-area:2/1] z-[2] justify-self-end backdrop-blur-md leading-none self-start ring-1 ring-tint text-tint bg-transparent rounded-md mr-2 mt-2 p-1 hover:ring-tint-hover"
/>
<pre
className={tcls(
'[grid-area:2/1] relative overflow-auto bg-tint theme-gradient:bg-tint-12/1 ring-tint-subtle hide-scroll',
'rounded-md straight-corners:rounded-sm',
title && 'rounded-ss-none',
)}
>
<code
id={id}
className={tcls(
'min-w-full inline-grid grid-cols-[auto_1fr] p-2 [count-reset:line]',
withWrap && 'whitespace-pre-wrap',
)}
>
{lines.map((line, index) => (
<CodeHighlightLine
key={index}
line={line}
isLast={index === lines.length - 1}
withLineNumbers={withLineNumbers}
/>
))}
</code>
</pre>
</div>
);
});
function CodeHighlightLine(props: {
line: HighlightLine;
isLast: boolean;
withLineNumbers: boolean;
}) {
const { line, isLast, withLineNumbers } = props;
return (
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
{withLineNumbers && <span className="highlight-line-number" />}
<span className="highlight-line-content">
<CodeHighlightTokens tokens={line.tokens} />
{!isLast && '\n'}
</span>
</span>
);
}
function CodeHighlightTokens(props: { tokens: HighlightToken[] }) {
const { tokens } = props;
return tokens.map((token, index) => <CodeHighlightToken key={index} token={token} />);
}
function CodeHighlightToken(props: { token: HighlightToken }) {
const { token } = props;
switch (token.type) {
case 'annotation': {
return (
<AnnotationPopover body={token.body}>
<CodeHighlightTokens tokens={token.children} />
</AnnotationPopover>
);
}
case 'plain': {
return token.content;
}
case 'shiki': {
if (!token.token.color) {
return token.token.content;
}
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
}
default:
assertNever(token);
}
}
@@ -53,8 +53,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
}} }}
block={block} block={block}
ancestorBlocks={[]} ancestorBlocks={[]}
// We optimize perf by default isEstimatedOffscreen={false}
isEstimatedOffscreen
/> />
); );
} }
@@ -1,18 +1,9 @@
import type { DocumentBlockCode } from '@gitbook/api';
import { it, expect } from 'bun:test'; import { it, expect } from 'bun:test';
import { getInlines, highlight, RenderedInline } from './highlight'; import { highlight } from './highlight';
async function highlightWithInlines(block: DocumentBlockCode) {
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
inline,
body: null,
}));
return highlight(block, inlines);
}
it('should parse plain code', async () => { it('should parse plain code', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
data: {}, data: {},
@@ -47,35 +38,32 @@ it('should parse plain code', async () => {
it('should parse different code in parallel', async () => { it('should parse different code in parallel', async () => {
await Promise.all( await Promise.all(
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) => ['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
highlight( highlight({
{ object: 'block',
object: 'block', type: 'code',
type: 'code', data: {
data: { syntax: syntax,
syntax: syntax,
},
nodes: [
{
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
},
],
}, },
[], nodes: [
), {
object: 'block',
type: 'code-line',
data: {},
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
},
],
},
],
}),
), ),
); );
}); });
it('should parse a multilines plain code', async () => { it('should parse a multilines plain code', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
data: {}, data: {},
@@ -150,7 +138,7 @@ it('should parse a multilines plain code', async () => {
}); });
it('should parse code with an inline on a single line', async () => { it('should parse code with an inline on a single line', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
data: { data: {
@@ -204,8 +192,10 @@ it('should parse code with an inline on a single line', async () => {
}, },
}, },
{ {
type: 'annotation', type: 'inline',
body: null, inline: {
type: 'annotation',
},
children: [ children: [
{ {
type: 'shiki', type: 'shiki',
@@ -239,7 +229,7 @@ it('should parse code with an inline on a single line', async () => {
}); });
it('should parse code with an inline on a multiple line', async () => { it('should parse code with an inline on a multiple line', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
data: { data: {
@@ -320,8 +310,10 @@ it('should parse code with an inline on a multiple line', async () => {
}, },
}, },
{ {
type: 'annotation', type: 'inline',
body: null, inline: {
type: 'annotation',
},
children: [ children: [
{ {
type: 'shiki', type: 'shiki',
@@ -355,8 +347,10 @@ it('should parse code with an inline on a multiple line', async () => {
}, },
}, },
{ {
type: 'annotation', type: 'inline',
body: null, inline: {
type: 'annotation',
},
children: [ children: [
{ {
type: 'shiki', type: 'shiki',
@@ -390,7 +384,7 @@ it('should parse code with an inline on a multiple line', async () => {
}); });
it('should support code token finishing before the end of the annotation', async () => { it('should support code token finishing before the end of the annotation', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
isVoid: false, isVoid: false,
@@ -476,8 +470,10 @@ it('should support code token finishing before the end of the annotation', async
}, },
}, },
{ {
type: 'annotation', type: 'inline',
body: null, inline: {
type: 'annotation',
},
children: [ children: [
{ {
type: 'shiki', type: 'shiki',
@@ -511,7 +507,7 @@ it('should support code token finishing before the end of the annotation', async
}); });
it('should support multiple code tokens in an annotation', async () => { it('should support multiple code tokens in an annotation', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
isVoid: false, isVoid: false,
@@ -619,8 +615,11 @@ it('should support multiple code tokens in an annotation', async () => {
}, },
}, },
{ {
type: 'annotation', type: 'inline',
body: null, inline: {
object: 'inline',
type: 'annotation',
},
children: [ children: [
{ {
type: 'shiki', type: 'shiki',
@@ -654,7 +653,7 @@ it('should support multiple code tokens in an annotation', async () => {
}); });
it('should handle \\r', async () => { it('should handle \\r', async () => {
const tokens = await highlightWithInlines({ const tokens = await highlight({
object: 'block', object: 'block',
type: 'code', type: 'code',
data: { data: {
@@ -1,327 +1,336 @@
import { DocumentBlockCode, DocumentBlockCodeLine, DocumentInlineAnnotation } from '@gitbook/api'; import { DocumentBlockCode, DocumentBlockCodeLine, DocumentInlineAnnotation } from '@gitbook/api';
import { // // @ts-ignore - onigWasm is a Wasm module
createdBundledHighlighter, // import {
ThemedToken, // createdBundledHighlighter,
createCssVariablesTheme, // ThemedToken,
createSingletonShorthands, // createCssVariablesTheme,
} from 'shiki/core'; // HighlighterGeneric,
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; // } from 'shiki/core';
import { BundledLanguage, bundledLanguages } from 'shiki/langs'; // // @ts-ignore - onigWasm is a Wasm module
// import { loadWasm, createOnigurumaEngine } from 'shiki/engine/oniguruma';
// // @ts-ignore - onigWasm is a Wasm module
// import { bundledLanguages } from 'shiki/langs';
// // @ts-ignore - onigWasm is a Wasm module
// import onigWasm from 'shiki/onig.wasm?module';
import { plainHighlight } from './plain-highlight'; // import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
// import { trace } from '@/lib/tracing';
export type HighlightLine = { export type HighlightLine = {
highlighted: boolean; highlighted: boolean;
tokens: HighlightToken[]; tokens: HighlightToken[];
}; };
export type HighlightToken = export type HighlightToken =
| { type: 'plain'; content: string } | { type: 'plain'; content: string }
| { type: 'shiki'; token: ThemedToken } | { type: 'inline'; inline: DocumentInlineAnnotation; children: HighlightToken[] };
| { type: 'annotation'; body: React.ReactNode; children: HighlightToken[] };
export type InlineIndexed = { inline: any; start: number; end: number }; // | { type: 'shiki'; token: ThemedToken }
type PositionedToken = ThemedToken & { start: number; end: number }; // type InlineIndexed = { inline: any; start: number; end: number };
export type RenderedInline = { // type PositionedToken = ThemedToken & { start: number; end: number };
inline: InlineIndexed;
body: React.ReactNode;
};
const theme = createCssVariablesTheme(); // /**
// * Highlight a code block while preserving inline elements.
// */
export async function highlight(block: DocumentBlockCode): Promise<HighlightLine[]> {
return plainHighlighting(block);
// const langName = block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
// if (!langName) {
// // Language not found, fallback to plain highlighting
// return plainHighlighting(block);
// }
const highlighter = createSingletonShorthands( // const inlines: InlineIndexed[] = [];
createdBundledHighlighter<any, any>({ // const code = getPlainCodeBlock(block, inlines);
langs: bundledLanguages,
themes: {},
engine: () =>
createJavaScriptRegexEngine({
forgiving: true,
}),
}),
);
/** // inlines.sort((a, b) => {
* Preload the highlighter for a code block. // return a.start - b.start;
*/ // });
export async function preloadHighlight(block: DocumentBlockCode) {
const langName = getBlockLang(block); // const highlighter = await loadHighlighter();
if (langName) { // await loadHighlighterLanguage(highlighter, langName);
await highlighter.getSingletonHighlighter({
langs: [langName], // const lines = highlighter.codeToTokensBase(code, {
themes: [theme], // lang: langName,
}); // tokenizeMaxLineLength: 400,
} // });
// let currentIndex = 0;
// return lines.map((tokens, index) => {
// const lineBlock = block.nodes[index];
// const result: HighlightToken[] = [];
// const eatToken = (): PositionedToken | null => {
// const token = tokens.shift();
// if (token) {
// currentIndex += token.content.length;
// }
// return token
// ? { ...token, start: currentIndex - token.content.length, end: currentIndex }
// : null;
// };
// while (tokens.length > 0) {
// result.push(...matchTokenAndInlines(eatToken, inlines));
// }
// currentIndex += 1; // for the \n
// return {
// highlighted: !!lineBlock.data.highlighted,
// tokens: result,
// };
// });
} }
/** // /**
* Highlight a code block while preserving inline elements. // * Validate a language name.
*/ // */
export async function highlight( // function getLanguageForSyntax(syntax: string): keyof typeof bundledLanguages | null {
block: DocumentBlockCode, // // @ts-ignore
inlines: RenderedInline[], // const lang = bundledLanguages[syntax];
): Promise<HighlightLine[]> { // if (!lang) {
const langName = getBlockLang(block); // return null;
if (!langName) { // }
// Language not found, fallback to plain highlighting
return plainHighlight(block, inlines);
}
const code = getPlainCodeBlock(block); // // @ts-ignore
// return syntax;
// }
const lines = await highlighter.codeToTokensBase(code, { // /**
lang: langName, // * Parse a code block without highlighting it.
theme, // */
tokenizeMaxLineLength: 400, export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
}); return block.nodes.map((lineBlock: any) => {
const tokens: HighlightToken[] = [];
let currentIndex = 0; for (const node of lineBlock.nodes) {
return lines.map((tokens, index) => { if (node.object === 'text') {
const lineBlock = block.nodes[index]; tokens.push({
const result: HighlightToken[] = []; type: 'plain',
content: getNodeText(node),
const eatToken = (): PositionedToken | null => {
const token = tokens.shift();
if (token) {
currentIndex += token.content.length;
}
return token
? { ...token, start: currentIndex - token.content.length, end: currentIndex }
: null;
};
while (tokens.length > 0) {
result.push(...matchTokenAndInlines(eatToken, inlines));
}
currentIndex += 1; // for the \n
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens: result,
};
});
}
/**
* Get the language of a code block.
*/
function getBlockLang(block: DocumentBlockCode): string | null {
return block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
}
const syntaxAliases: Record<string, BundledLanguage> = {
// "Parser" language does not exist in Shiki, but it's used in GitBook
// The closest language is "Blade"
parser: 'blade',
};
function checkIsBundledLanguage(lang: string): lang is BundledLanguage {
return lang in bundledLanguages;
}
/**
* Validate a language name.
*/
function getLanguageForSyntax(syntax: string): BundledLanguage | null {
// Normalize the syntax to lowercase.
syntax = syntax.toLowerCase();
// Temporary disable highlighting for C/C++ code blocks
// @see https://github.com/shikijs/shiki/issues/893
if (syntax === 'cpp' || syntax === 'c') {
return null;
}
// Check if the syntax is a bundled language.
if (checkIsBundledLanguage(syntax)) {
return syntax;
}
// Check if there is a valid alias for the syntax.
const alias = syntaxAliases[syntax];
if (alias && checkIsBundledLanguage(alias)) {
return alias;
}
return null;
}
export function getInlines(block: DocumentBlockCode) {
const inlines: InlineIndexed[] = [];
getPlainCodeBlock(block, inlines);
inlines.sort((a, b) => a.start - b.start);
return inlines;
}
function matchTokenAndInlines(
eat: () => PositionedToken | null,
allInlines: RenderedInline[],
): HighlightToken[] {
const initialToken = eat();
if (!initialToken) {
return [];
}
const inlines = allInlines.filter(
({ inline }) => inline.start >= initialToken.start && inline.start < initialToken.end,
);
let token = initialToken;
const result: HighlightToken[] = [];
const matchAgainstInline = () => {
const inline = inlines.shift();
if (!inline) {
result.push({
type: 'shiki',
token,
});
return;
}
const [before, afterBefore] = splitPositionedTokenAt(token, inline.inline.start);
if (before) {
result.push({
type: 'shiki',
token: before,
});
}
if (!afterBefore) {
throw new Error(`expect afterBefore to not be empty`);
}
token = afterBefore;
const children: HighlightToken[] = [];
// If shiki token finished before the end of the annotation or the annotation contains multiple tokens
while (token.end < inline.inline.end) {
children.push({
type: 'shiki',
token: token,
});
const next = eat();
if (!next) {
throw new Error(`expect token to not be empty`);
}
token = next;
}
const [inside, after] = splitPositionedTokenAt(token, inline.inline.end);
if (!inside) {
throw new Error(`expect inside to not be empty`);
}
children.push({
type: 'shiki',
token: inside,
});
result.push({
type: 'annotation',
body: inline.body,
children,
});
if (after) {
token = after;
matchAgainstInline();
}
};
matchAgainstInline();
return result;
}
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
let content = '';
code.nodes.forEach((node, index) => {
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
content += lineContent;
if (index < code.nodes.length - 1) {
content += '\n';
}
});
return content;
}
function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
index: number,
inlines?: InlineIndexed[],
): string {
let content = '';
for (const node of parent.nodes) {
if (node.object === 'text') {
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
} else {
const start = index + content.length;
content += getPlainCodeBlockLine(node, index + content.length, inlines);
const end = index + content.length;
if (inlines) {
inlines.push({
inline: node,
start,
end,
}); });
} }
} }
}
return content; return {
highlighted: !!lineBlock.data.highlighted,
tokens,
};
});
} }
function slicePositionedToken( // function matchTokenAndInlines(
token: PositionedToken, // eat: () => PositionedToken | null,
relativeStart: number, // allInlines: InlineIndexed[],
relativeLength: number, // ): HighlightToken[] {
): PositionedToken { // const initialToken = eat();
return { // if (!initialToken) {
...token, // return [];
start: token.start + relativeStart, // }
end: token.start + relativeStart + relativeLength,
content: token.content.slice(relativeStart, relativeStart + relativeLength),
};
}
function splitPositionedTokenAt( // const inlines = allInlines.filter(
token: PositionedToken, // (inline) => inline.start >= initialToken.start && inline.start < initialToken.end,
absoluteIndex: number, // );
): [PositionedToken | null, PositionedToken | null] { // let token = initialToken;
if (absoluteIndex < token.start || absoluteIndex > token.end) { // const result: HighlightToken[] = [];
throw new Error(`index (${absoluteIndex}) out of bound (${token.start}:${token.end})`);
}
const before = slicePositionedToken(token, 0, absoluteIndex - token.start); // const matchAgainstInline = () => {
const after = slicePositionedToken( // const inline = inlines.shift();
token, // if (!inline) {
absoluteIndex - token.start, // result.push({
token.end - absoluteIndex, // type: 'shiki',
); // token,
// });
return [ // return;
isEmptyPositionedToken(before) ? null : before, // }
isEmptyPositionedToken(after) ? null : after,
];
}
function isEmptyPositionedToken(token: PositionedToken): boolean { // const [before, afterBefore] = splitPositionedTokenAt(token, inline.start);
return token.start === token.end; // if (before) {
} // result.push({
// type: 'shiki',
// token: before,
// });
// }
// if (!afterBefore) {
// throw new Error(`expect afterBefore to not be empty`);
// }
/** // token = afterBefore;
* Currently it's possible for some lines to contain \r characters, we need to remove them // const children: HighlightToken[] = [];
* as they are considered as new lines by shikijs.
*/ // // If shiki token finished before the end of the annotation or the annotation contains multiple tokens
function cleanupLine(line: string): string { // while (token.end < inline.end) {
return line.replace(/\r/g, ''); // children.push({
} // type: 'shiki',
// token: token,
// });
// const next = eat();
// if (!next) {
// throw new Error(`expect token to not be empty`);
// }
// token = next;
// }
// const [inside, after] = splitPositionedTokenAt(token, inline.end);
// if (!inside) {
// throw new Error(`expect inside to not be empty`);
// }
// children.push({
// type: 'shiki',
// token: inside,
// });
// result.push({
// type: 'inline',
// inline: inline.inline,
// children,
// });
// if (after) {
// token = after;
// matchAgainstInline();
// }
// };
// matchAgainstInline();
// return result;
// }
// function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): string {
// let content = '';
// code.nodes.forEach((node, index) => {
// const lineContent = getPlainCodeBlockLine(node, inlines, content.length);
// content += lineContent;
// if (index < code.nodes.length - 1) {
// content += '\n';
// }
// });
// return content;
// }
// function getPlainCodeBlockLine(
// parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
// inlines: InlineIndexed[],
// index: number,
// ): string {
// let content = '';
// for (const node of parent.nodes) {
// if (node.object === 'text') {
// content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
// } else {
// const start = index + content.length;
// content += getPlainCodeBlockLine(node, inlines, index + content.length);
// const end = index + content.length;
// inlines.push({
// inline: node,
// start,
// end,
// });
// }
// }
// return content;
// }
// function slicePositionedToken(
// token: PositionedToken,
// relativeStart: number,
// relativeLength: number,
// ): PositionedToken {
// return {
// ...token,
// start: token.start + relativeStart,
// end: token.start + relativeStart + relativeLength,
// content: token.content.slice(relativeStart, relativeStart + relativeLength),
// };
// }
// function splitPositionedTokenAt(
// token: PositionedToken,
// absoluteIndex: number,
// ): [PositionedToken | null, PositionedToken | null] {
// if (absoluteIndex < token.start || absoluteIndex > token.end) {
// throw new Error(`index (${absoluteIndex}) out of bound (${token.start}:${token.end})`);
// }
// const before = slicePositionedToken(token, 0, absoluteIndex - token.start);
// const after = slicePositionedToken(
// token,
// absoluteIndex - token.start,
// token.end - absoluteIndex,
// );
// return [
// isEmptyPositionedToken(before) ? null : before,
// isEmptyPositionedToken(after) ? null : after,
// ];
// }
// function isEmptyPositionedToken(token: PositionedToken): boolean {
// return token.start === token.end;
// }
// /**
// * Currently it's possible for some lines to contain \r characters, we need to remove them
// * as they are considered as new lines by shikijs.
// */
// function cleanupLine(line: string): string {
// return line.replace(/\r/g, '');
// }
// const createHighlighter = createdBundledHighlighter<any, any>({
// langs: bundledLanguages,
// themes: {},
// engine: () => createOnigurumaEngine(import('shiki/wasm')),
// });
// /**
// * Load the highlighter, only once, and reuse it.
// * It makes sure to handle concurrent calls.
// */
// const loadHighlighter = singleton(async () => {
// return await trace('highlighting.loadHighlighter', async () => {
// if (typeof onigWasm !== 'string') {
// // When running bun test, the import is a string, we ignore it and let the module
// // loads it on its own.
// //
// // Otherwise for Vercel/Cloudflare, we need to load it ourselves.
// await loadWasm((obj) => WebAssembly.instantiate(onigWasm, obj));
// }
// const highlighter = await createHighlighter({
// themes: [createCssVariablesTheme()],
// langs: [],
// });
// return highlighter;
// });
// });
// const loadLanguagesMutex = asyncMutexFunction();
// async function loadHighlighterLanguage(
// highlighter: HighlighterGeneric<any, any>,
// lang: keyof typeof bundledLanguages,
// ) {
// await loadLanguagesMutex.runBlocking(async () => {
// if (highlighter.getLoadedLanguages().includes(lang)) {
// return;
// }
// await trace(
// `highlighting.loadLanguage(${lang})`,
// async () => await highlighter.loadLanguage(lang),
// );
// });
// }
@@ -1,41 +0,0 @@
import { DocumentBlockCode } from '@gitbook/api';
import { getNodeText } from '@/lib/document';
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
/**
* Parse a code block without highlighting it.
*/
export function plainHighlight(
block: DocumentBlockCode,
inlines: RenderedInline[],
): HighlightLine[] {
const inlinesCopy = Array.from(inlines);
return block.nodes.map((lineBlock) => {
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
if (node.object === 'text') {
return {
type: 'plain',
content: getNodeText(node),
};
}
const inline = inlinesCopy.shift();
return {
type: 'annotation',
body: inline?.body ?? null,
children: [
{
type: 'plain',
content: getNodeText(node),
},
],
};
});
return {
highlighted: Boolean(lineBlock.data.highlighted),
tokens,
};
});
}
@@ -1,5 +1,5 @@
:root { :root {
--shiki-color-text: theme('colors.tint.11'); --shiki-color-text: theme('colors.dark.DEFAULT/.88');
--shiki-token-constant: #0a6355; --shiki-token-constant: #0a6355;
--shiki-token-string: #8b6d32; --shiki-token-string: #8b6d32;
--shiki-token-comment: theme('colors.teal.700/.64'); --shiki-token-comment: theme('colors.teal.700/.64');
@@ -8,21 +8,21 @@
--shiki-token-function: #8250df; --shiki-token-function: #8250df;
--shiki-token-string-expression: #6a4906; --shiki-token-string-expression: #6a4906;
--shiki-token-punctuation: theme('colors.pomegranate.700/.92'); --shiki-token-punctuation: theme('colors.pomegranate.700/.92');
--shiki-token-link: theme('colors.tint.12'); --shiki-token-link: theme('colors.dark.DEFAULT');
--shiki-token-inserted: #22863a; --shiki-token-inserted: #22863a;
--shiki-token-deleted: #b31d28; --shiki-token-deleted: #b31d28;
--shiki-token-changed: #8250df; --shiki-token-changed: #8250df;
} }
html.dark { html.dark {
--shiki-color-text: theme('colors.tint.11'); --shiki-color-text: theme('colors.light.DEFAULT/0.88');
--shiki-token-constant: #d19a66; --shiki-token-constant: #d19a66;
--shiki-token-string: theme('colors.pomegranate.300'); --shiki-token-string: theme('colors.pomegranate.300');
--shiki-token-comment: theme('colors.teal.300/.64'); --shiki-token-comment: theme('colors.teal.300/.64');
--shiki-token-keyword: theme('colors.pomegranate.400'); --shiki-token-keyword: theme('colors.pomegranate.400');
--shiki-token-parameter: theme('colors.yellow.500'); --shiki-token-parameter: theme('colors.yellow.500');
--shiki-token-function: #56b6c2; --shiki-token-function: #56b6c2;
--shiki-token-string-expression: theme('colors.tint.11'); --shiki-token-string-expression: theme('colors.light.4');
--shiki-token-punctuation: #acc6ee; --shiki-token-punctuation: #acc6ee;
--shiki-token-link: theme('colors.pomegranate.400'); --shiki-token-link: theme('colors.pomegranate.400');
--shiki-token-inserted: #85e89d; --shiki-token-inserted: #85e89d;
@@ -7,5 +7,5 @@ import { BlockProps } from './Block';
export function Divider(props: BlockProps<DocumentBlockDivider>) { export function Divider(props: BlockProps<DocumentBlockDivider>) {
const { style } = props; const { style } = props;
return <hr className={tcls(style, 'border-tint-subtle')} />; return <hr className={tcls(style, 'border-dark/2', 'dark:border-light/2')} />;
} }
@@ -1,10 +1,10 @@
import * as gitbookAPI from '@gitbook/api'; import * as gitbookAPI from '@gitbook/api';
import { headers } from 'next/headers';
import Script from 'next/script'; import Script from 'next/script';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Card } from '@/components/primitives'; import { Card } from '@/components/primitives';
import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api'; import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block'; import { BlockProps } from './Block';
@@ -13,7 +13,7 @@ import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) { export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props; const { block, context, ...otherProps } = props;
const nonce = await getContentSecurityPolicyNonce(); const nonce = headers().get('x-nonce') || undefined;
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce }); ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
@@ -22,20 +22,15 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
: getEmbedByUrl(block.data.url)); : getEmbedByUrl(block.data.url));
return ( return (
<Caption {...props} withBorder> <Caption {...props}>
{embed.type === 'rich' ? ( {embed.type === 'rich' ? (
<> <>
<div <div
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: embed.html, __html: embed.html,
}} }}
data-visual-test="blackout"
/>
<Script
strategy="lazyOnload"
src="https://cdn.iframe.ly/embed.js"
nonce={nonce}
/> />
<Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} />
</> </>
) : embed.type === 'integration' ? ( ) : embed.type === 'integration' ? (
<IntegrationBlock <IntegrationBlock
@@ -45,10 +45,13 @@ export function Details(props: {
className={tcls( className={tcls(
className, className,
'group/expandable', 'group/expandable',
'bg-tint-base', 'shadow-dark/1',
'bg-gradient-to-t',
'from-light-1',
'to-light-1',
'border', 'border',
'border-b-0', 'border-b-0',
'border-tint-subtle', 'border-dark-3/3',
//all //all
'[&]:mt-[0px]', '[&]:mt-[0px]',
//select first child //select first child
@@ -61,6 +64,15 @@ export function Details(props: {
'[&:not(:has(+_&))]:mb-5', '[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg', '[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b', '[&:not(:has(+_&))]:border-b',
/* '[&:not(:has(+_&))]:shadow-1xs', */
'dark:border-light-2/[0.06]',
'dark:from-dark-2',
'dark:to-dark-2',
'dark:shadow-none',
'group open:dark:to-dark-2/8',
'group open:to-light-1/6',
)} )}
> >
{children} {children}

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