Compare commits

..

62 Commits

Author SHA1 Message Date
Greg Bergé de2d468677 Fix build 2025-01-09 10:03:57 +01:00
Greg Bergé 7f50ad68bb Remove import 2025-01-09 09:49:51 +01:00
Greg Bergé 3a759b14a4 Fix lockfile 2025-01-09 09:46:55 +01:00
Greg Bergé b5e2e3fa8d Setup Sentry with Cloudflare 2025-01-09 09:40:47 +01:00
Greg Bergé fec48ce708 Import Sentry in middleware 2025-01-09 09:21:52 +01:00
Greg Bergé 0784bf5df7 Update wrangler 2025-01-09 08:49:48 +01:00
Greg Bergé f8f485ae25 Add log 2025-01-09 08:48:44 +01:00
Greg Bergé 3491bdc522 Import Sentry in middleware 2025-01-08 17:14:16 +01:00
Greg Bergé 2cda8d4cad Add more logs 2025-01-08 16:51:07 +01:00
Greg Bergé 3fcbe46262 Introduce error on purpose 2025-01-08 16:34:30 +01:00
Greg Bergé 884ee7073f Simplify Sentry setup, add logs 2025-01-08 16:34:27 +01:00
Greg Bergé 88397bf419 Flush Sentry events manually server-side (#2698) 2025-01-08 14:57:15 +01:00
Valentino Hudhra 1138d5935b Add sidebar new background styles (#2645)
Co-authored-by: Zeno Kapitein <admin@zeno.zone>
2025-01-08 14:01:06 +01:00
Greg Bergé 8276ba080e Make cookies access safer (#2694) 2025-01-08 13:12:41 +01:00
Greg Bergé 37d13d80f5 Pass string URL to fetch to avoid errors (#2695) 2025-01-08 13:01:10 +01:00
Greg Bergé 1c97536ab1 Fix Sentry instrumentation (again) (#2697) 2025-01-08 12:28:08 +01:00
Greg Bergé ca8f028cc0 Fix error in header mobile menu (#2696) 2025-01-08 11:09:58 +00:00
Claire Chabas 300f7bfe4b Fix search loading state (#2684) 2025-01-08 11:52:12 +01:00
Greg Bergé 82dc9c40f7 Simplify useHash algo used (#2689) 2025-01-08 11:37:48 +01:00
Greg Bergé 44a20fe5ee Improve scroll listener (#2692) 2025-01-08 11:17:22 +01:00
Greg Bergé b950a64406 improve browser support (#2693) 2025-01-08 11:08:29 +01:00
Greg Bergé c5f374f66f Move instrumentation to src folder (#2691) 2025-01-08 10:55:56 +01:00
Greg Bergé ae99f87db0 Improve emoji setup (#2688) 2025-01-08 10:11:35 +01:00
Greg Bergé 5112e3e79b Fix Sentry instrumentation server-side (#2690) 2025-01-08 09:52:02 +01:00
Greg Bergé c1e27ccacc Fix pass Sentry release properly (#2687) 2025-01-08 09:12:34 +01:00
Greg Bergé b0bd871997 Even safer local storage... (#2686) 2025-01-07 17:57:02 +01:00
Greg Bergé c30bc24fd6 Fix empty sitemap (#2685) 2025-01-07 17:09:51 +01:00
Greg Bergé 648f0e9e84 Various fixes (#2683) 2025-01-07 16:54:10 +01:00
Greg Bergé d2bc5672e0 Set Sentry release (#2682) 2025-01-07 16:36:44 +01:00
Greg Bergé 5c87ec7c2f Safer way to interact with localStorage (#2679) 2025-01-07 15:51:23 +01:00
Greg Bergé d66c184ed2 Ignore errors from event flushing (#2681) 2025-01-07 15:38:18 +01:00
Greg Bergé 12f25d8bfc Fix error from visitor id not set (#2678) 2025-01-07 15:18:05 +01:00
Greg Bergé cbe61397a3 Fix dynamic tabs infinite loop (#2677) 2025-01-07 14:54:28 +01:00
Greg Bergé deb8c54da3 Upgrade Next.js to v15 (#2671) 2025-01-07 10:42:02 +01:00
Greg Bergé 5dab70fab4 Fix "Parser" language syntax highlighting (#2676) 2025-01-07 10:23:25 +01:00
Greg Bergé 665b6bed66 Ignore invalid API calls to getSiteRedirectFromSource API (#2675) 2025-01-07 10:06:31 +01:00
Greg Bergé f4a90defc3 Remove Recoil (#2674) 2025-01-06 16:06:59 +01:00
Greg Bergé 46f63cbd55 Fix code format overriding (#2673)
Co-authored-by: eztaK-red <eztak.red@gmail.com>
2025-01-06 15:15:39 +01:00
Greg Bergé c77142a16f Log component stack in Sentry (#2672) 2025-01-06 13:14:22 +01:00
Steven H eb7c22f52a Revert Scalar to a previous version to mitigate an app crash happening when switching between API pages. (#2670) 2025-01-06 11:57:51 +00:00
Steven H e86e51f06f Fix an issue where the redirects of potentially malicious images were not going through. (#2666) 2024-12-30 14:41:54 +00:00
Steven H db74ea3001 Image optimization endpoint redirects to underlying image URL if the signature is not the latest. (#2665) 2024-12-30 13:08:49 +00:00
Steven H 99579ac29c Fix a vulnerability issue for images using an older version of the image signing parameter. (#2664) 2024-12-30 11:29:30 +00:00
Samy Pessé 2f6540e008 Don't cache VA and adaptive content responses (#2663) 2024-12-27 17:56:42 +01:00
Samy Pessé 72c3b80529 Pass visitor auth token to site insights events (#2662) 2024-12-26 19:06:29 +01:00
Viktor Renkema 8126a83c50 Reintro vertical table lines + hover state (#2653) 2024-12-25 08:41:54 +01:00
Samy Pessé 210c432b37 Fix PDF export crashing (#2661) 2024-12-23 14:52:50 +01:00
Amrit Kahlon f92e90603f Prevent multiple versions of codemirror from loading (#2660)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
2024-12-21 23:46:16 +01:00
Samy Pessé 14172791e9 Track click on links for site insights (#2659) 2024-12-21 18:18:30 +01:00
Samy Pessé e4e2f524d4 Track site insight event when opening scalar client (#2658) 2024-12-21 15:57:10 +01:00
Amrit Kahlon fc7b16f6a7 Update scalar dependency (#2652) 2024-12-20 23:16:11 +01:00
Samy Pessé 6ba3ae74d5 Workaround until referrer is accepted by API as an empty string (#2656) 2024-12-20 19:23:52 +01:00
Samy Pessé c71d1598d8 Insights site events support (#2655) 2024-12-20 19:06:50 +01:00
Greg Bergé 5950657b0e Fix emojis display (#2648) 2024-12-20 11:30:56 +01:00
Samy Pessé 0e1a48cd06 Upgrade bun and setup-bun, switch to text lockfile (#2651)
Co-authored-by: Greg Bergé <berge.greg@gmail.com>
2024-12-20 11:19:54 +01:00
spastorelli 98245e5f20 Adapt code to pull token from customer backend generated custom cookies (#2650)
Co-authored-by: Steven Hall <steven@gitbook.io>
2024-12-19 18:29:32 +01:00
Zeno Kapitein 0b6ddca981 Fix variant selector for non-default themes (#2649) 2024-12-19 12:28:13 +01:00
spastorelli ea1468c892 Send redirectOnError param to getPublishedContent when token is pulled from cookie (#2626) 2024-12-19 11:16:06 +01:00
Greg Bergé e8e64bf510 Fix bullet list display on full size blocks (#2646) 2024-12-18 13:55:18 +01:00
Greg Bergé aaf8daf0d7 Improve search UI (#2643) 2024-12-18 13:42:47 +01:00
Zeno Kapitein 8af1abc4e1 Improve contrast of search box placeholder (#2639) 2024-12-18 12:14:06 +01:00
Zeno Kapitein 48ab59feb3 Improve contrast of list decoration (#2638) 2024-12-18 11:49:01 +01:00
181 changed files with 3160 additions and 1663 deletions
+1
View File
@@ -0,0 +1 @@
1.1.40
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix empty sitemap
+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': patch
---
Log component stack in Sentry
+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
---
Fix pass Sentry release properly
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Add support for sidebar background styles
+7
View File
@@ -0,0 +1,7 @@
---
'gitbook': patch
---
Ignore invalid API calls to `getSiteRedirectFromSource` API
To reduce the load on the API and also avoid errors.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Ignore errors from event flushing
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve emoji setup, align with GitBook app
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix an issue where the redirects of potentially malicious images were not going through.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Make cookies access safer
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve contrast of search box placeholder
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Simplify the `useHash` algo used.
+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
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Set Sentry release
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': minor
'gitbook': minor
---
Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
+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/react-contentkit': patch
---
Fix potential invalid URL error in react-contentkit
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Avoid error on fetch by passing a string URL
+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 code format overriding inline link styles
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix emojis display
+7
View File
@@ -0,0 +1,7 @@
---
'gitbook': patch
---
Implement a safer way to interact with localStorage.
If it's disabled on the browser it should not throw error.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix dynamic tabs infinite loop
+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 search loading state
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix Sentry instrumentation
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Even safer localStorage
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Avoid errors on legacy browsers
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Improve readability of tables with hover style and vertical dividers
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
+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': patch
---
Fix "Parser" language syntax highlighting
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Fix a vulnerability issue for images using an older version of the image signing parameter.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Track an event into site insights when visitor is opening the Scalar API client.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Improve smoothness of scroll listener
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix Sentry instrumentation server-side
+18
View File
@@ -0,0 +1,18 @@
name: 'Setup Bun'
description: 'Install Bun and cache dependencies'
runs:
using: 'composite'
steps:
# We cache dependencies even if Bun is fast to avoid downloading fontawesome too many times
# and impacting bandwidth there.
- name: Cache bun
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: '.bun-version'
+16 -73
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
env:
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
jobs:
deploy:
name: Deploy to Cloudflare Pages
@@ -22,10 +22,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -46,6 +44,7 @@ jobs:
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
@@ -95,62 +94,14 @@ jobs:
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
edit-mode: replace
visual-testing:
runs-on: ubuntu-latest
name: Visual Testing
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
- name: Run Playwright tests
run: bun e2e
env:
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-test-results
path: packages/gitbook/test-results/
retention-days: 3
pagespeed-testing:
runs-on: ubuntu-latest
name: PageSpeed Testing
needs: deploy
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Install dependencies
run: bun install --frozen-lockfile
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- name: Run pagespeed tests
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
env:
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
format:
runs-on: ubuntu-latest
name: Format
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -162,10 +113,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -177,10 +126,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -191,16 +138,14 @@ jobs:
runs-on: ubuntu-latest
name: Build (Open Source)
env:
NPMRC_FONT_AWESOME_TOKEN: ''
NPM_TOKEN_READONLY: ''
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
run: bun install
env:
PUPPETEER_SKIP_DOWNLOAD: 1
- run: bun run build
@@ -210,10 +155,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+5 -9
View File
@@ -17,10 +17,8 @@ jobs:
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
@@ -34,7 +32,7 @@ jobs:
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
release-preview:
@@ -45,10 +43,8 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.18
- name: Setup Bun
uses: ./.github/composite/setup-bun
- name: Install dependencies
run: bun install --frozen-lockfile
env:
+20
View File
@@ -78,6 +78,26 @@ Any published GitBook site can be accessed through your local development instan
All pull-requests will be tested against both visual and performances testing to prevent regressions.
## Fonts and Icons
GitBook Open uses fontawesome. During development, your local environment will use the free version. However, only the pro version will be accepted by CI. If you see the following error:
```
The GitBook icon is missing. It indicates that the dependencies were installed without the correct font-awesome package. These changes have probably been persisted in the Bun lockfile. Read the README for more information.
```
It means that you've changed the GBO dependencies and bundled in the free version. Only GitBook staff can help with this - if you're not on the GitBook team, please ping us in the PR and we'll help get things moving.
If you are GitBook staff, you'll need our NPM token in your local environment.
```
.env.local
NPM_TOKEN_READONLY=xxx
```
and then reinstall dependencies.
## Contributing
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,2 +1,2 @@
[install.scopes]
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
+8 -5
View File
@@ -7,13 +7,12 @@
"turbo": "^2.1.2"
},
"packageManager": "bun@1.1.18",
"patchedDependencies": {
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
"overrides": {
"@codemirror/state": "6.4.1"
},
"private": true,
"scripts": {
"dev": "turbo run dev",
"dev": "turbo run dev --ui=stream",
"build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare",
"lint": "turbo run lint",
@@ -31,5 +30,9 @@
},
"workspaces": [
"packages/*"
]
],
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
},
"devDependencies": {
"typescript": "^5.5.3",
"wrangler": "3.82.0"
"wrangler": "^3.100.0"
},
"scripts": {
"generate": "wrangler types --experimental-include-runtime",
+266 -35
View File
@@ -6,20 +6,29 @@ import {
CustomizationHeaderPreset,
CustomizationIconsStyle,
CustomizationLocale,
CustomizationSidebarBackgroundStyle,
CustomizationSidebarListStyle,
CustomizationThemeMode,
SiteCustomizationSettings,
} from '@gitbook/api';
import { test, expect, Page } from '@playwright/test';
import { test, expect, Page, BrowserContext } from '@playwright/test';
import deepMerge from 'deepmerge';
import jwt from 'jsonwebtoken';
import rison from 'rison';
import { DeepPartial } from 'ts-essentials';
import {
getVisitorAuthCookieName,
getVisitorAuthCookieValue,
VISITOR_TOKEN_COOKIE,
} from '@/lib/visitor-token';
import { getContentTestURL } from '../tests/utils';
interface Test {
name: string;
url: string; // URL to visit for testing
cookies?: Parameters<BrowserContext['addCookies']>[0];
run?: (page: Page) => Promise<unknown>; // The test to run
fullPage?: boolean; // Whether the test should be fullscreened during testing
screenshot?: false; // Should a screenshot be stored
@@ -51,6 +60,11 @@ const allThemePresets: CustomizationHeaderPreset[] = [
CustomizationHeaderPreset.Custom,
];
const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
CustomizationSidebarBackgroundStyle.Default,
CustomizationSidebarBackgroundStyle.Filled,
];
async function waitForCookiesDialog(page: Page) {
const dialog = page.getByRole('dialog', { name: 'Cookies' });
const accept = dialog.getByRole('button', { name: 'Accept' });
@@ -536,40 +550,47 @@ const testCases: TestsCase[] = [
}),
run: waitForCookiesDialog,
},
...allThemePresets.flatMap((preset) => ({
name: `With tint - Preset ${preset} - Theme ${theme}`,
url: getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
},
header: {
preset,
...(preset === CustomizationHeaderPreset.Custom
? {
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
linkColor: { light: '#4DDE98', dark: '#0C693D' },
}
: {}),
links: [
{
title: 'Secondary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-secondary',
// Theme-specific tests
...allThemePresets.flatMap((preset) => [
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
name: `With tint - Preset ${preset} - Sidebar ${sidebarStyle} - Theme ${theme}`,
url: getCustomizationURL({
styling: {
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
sidebar: {
background: sidebarStyle,
list: CustomizationSidebarListStyle.Default,
},
{
title: 'Primary button',
to: { kind: 'url', url: 'https://www.gitbook.com' },
style: 'button-primary',
},
],
},
themes: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
},
header: {
preset,
...(preset === CustomizationHeaderPreset.Custom
? {
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
linkColor: { light: '#4DDE98', dark: '#0C693D' },
}
: {}),
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: {
default: theme,
toggeable: false,
},
}),
run: waitForCookiesDialog,
})),
]),
{
name: `With tint - Legacy background match - Theme ${theme}`,
url: getCustomizationURL({
@@ -867,6 +888,60 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/`,
tests: [
{
name: 'Redirect to fallback on invalid token pulled from cookie',
url: '',
screenshot: false,
cookies: (() => {
const basePath = '/va-site-redirects-fallback/';
const invalidToken = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
},
);
return [
{
name: getVisitorAuthCookieName(basePath),
value: getVisitorAuthCookieValue(basePath, invalidToken),
httpOnly: true,
},
];
})(),
run: async (page) => {
await expect(page).toHaveURL(/https:\/\/www.google.com/);
},
},
{
name: 'Show error message when invalid token is passed to url',
screenshot: false,
url: (() => {
const token = jwt.sign(
{
name: 'gitbook-open-tests',
},
'invalidKey',
{
expiresIn: '24h',
},
);
return `?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(page.locator('pre')).toContainText(
'Error while validating the JWT token. Reason: The token signature is invalid.',
);
},
},
],
},
{
name: 'Languages',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1022,6 +1097,149 @@ const testCases: TestsCase[] = [
},
],
},
{
name: 'Adaptive Content - Public',
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/`,
tests: [
{
name: 'No custom cookie',
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isAlphaUser claim',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(alphaUserPage).toBeVisible();
await expect(betaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isBetaUser claim',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toHaveCount(0);
},
},
{
name: 'Custom cookie with isAlphaUser & isBetaUser claims',
cookies: (() => {
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
const token = jwt.sign(
{
name: 'gitbook-open-tests',
isAlphaUser: true,
isBetaUser: true,
},
privateKey,
{
expiresIn: '24h',
},
);
return [
{
name: VISITOR_TOKEN_COOKIE,
value: token,
httpOnly: true,
},
];
})(),
url: '',
run: async (page) => {
const welcomePage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Welcome Page' });
const alphaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Alpha User' });
const betaUserPage = page
.locator('a[class*="group\\/toclink"]')
.filter({ hasText: 'Beta User' });
await expect(welcomePage).toBeVisible();
await expect(betaUserPage).toBeVisible();
await expect(alphaUserPage).toBeVisible();
},
},
],
},
{
name: 'Tables',
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
@@ -1112,9 +1330,18 @@ 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 }) => {
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);
@@ -1152,6 +1379,10 @@ function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): s
font: CustomizationFont.Inter,
background: CustomizationBackground.Plain,
icons: CustomizationIconsStyle.Regular,
sidebar: {
background: CustomizationSidebarBackgroundStyle.Default,
list: CustomizationSidebarListStyle.Default,
},
},
internationalization: {
locale: CustomizationLocale.En,
+4 -7
View File
@@ -4,6 +4,7 @@ module.exports = withSentryConfig(
{
env: {
BUILD_VERSION: (process.env.GITHUB_SHA ?? '').slice(0, 7),
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? '',
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
@@ -67,19 +68,15 @@ module.exports = withSentryConfig(
},
},
{
silent: true,
release: process.env.SENTRY_RELEASE,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
authToken: process.env.SENTRY_AUTH_TOKEN,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: '/~gitbook/monitoring',
// Don't hide source maps from generated client bundles
hideSourceMaps: false,
disableLogger: true,
automaticVercelMonitors: false,
},
);
+12 -11
View File
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.81.0",
"@gitbook/api": "^0.85.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -25,7 +25,8 @@
"@gitbook/react-openapi": "workspace:*",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
"@sentry/cloudflare": "8.35.0",
"@sentry/nextjs": "8.35.0",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.10",
@@ -41,18 +42,17 @@
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"memoizee": "^0.4.15",
"next": "14.2.15",
"next": "15.1.3",
"next-themes": "^0.2.1",
"nuqs": "^1.17.4",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
"openapi-types": "^12.1.3",
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"postcss-color-contrast": "^1.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hotkeys-hook": "^4.4.1",
"recoil": "^0.7.7",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-gfm": "^4.0.0",
@@ -60,16 +60,17 @@
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.23.1",
"shiki": "^1.26.1",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
"url-join": "^5.0.0"
"url-join": "^5.0.0",
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "^1.13.5",
"@cloudflare/workers-types": "^4.20240725.0",
"@cloudflare/next-on-pages": "1.13.7",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
+4 -21
View File
@@ -1,27 +1,10 @@
import {
BrowserClient,
makeFetchTransport,
defaultStackParser,
getCurrentScope,
} from '@sentry/nextjs';
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.SENTRY_DSN;
if (dsn) {
// 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,
Sentry.init({
dsn,
integrations: [],
sampleRate: 0.1,
enableTracing: false,
beforeSendTransaction: () => {
return null;
},
transport: makeFetchTransport,
stackParser: defaultStackParser,
// Disable tracing as it creates additional requests in an env where subrequests are limited.
tracesSampleRate: 0,
});
getCurrentScope().setClient(client);
client.init();
}
-20
View File
@@ -1,20 +0,0 @@
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;
},
});
}
-20
View File
@@ -1,20 +0,0 @@
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 } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import {
CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
SignatureVersion,
verifyImageSignature,
resizeImage,
CloudflareImageOptions,
imagesResizingSignVersion,
checkIsSizableImageURL,
} from '@/lib/images';
} from '@/lib/image-signatures';
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images';
import { parseImageAPIURL } from '@/lib/urls';
export const runtime = 'edge';
@@ -20,12 +20,15 @@ export async function GET(request: NextRequest) {
let urlParam = request.nextUrl.searchParams.get('url');
const signature = request.nextUrl.searchParams.get('sign');
// The current signature algorithm sets version as 2, but we need to support the older version as well
const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined;
if (!urlParam || !signature) {
return new Response('Missing url/sign parameters', { status: 400 });
}
const signatureVersion = parseSignatureVersion(request.nextUrl.searchParams.get('sv'));
if (!signatureVersion) {
return new Response(`Invalid sv parameter`, { status: 400 });
}
const url = parseImageAPIURL(urlParam);
// Check again if the image can be sized, even though we checked when rendering the Image component
@@ -35,17 +38,16 @@ export async function GET(request: NextRequest) {
return new Response('Invalid url parameter', { status: 400 });
}
// For older signatures, we redirect to the url.
if (signatureVersion !== imagesResizingSignVersion) {
return Response.redirect(url, 302);
}
// Verify the signature
const verified = await verifyImageSignature(url, { signature });
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
if (!verified) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
}
if (signatureVersion !== CURRENT_SIGNATURE_VERSION) {
return NextResponse.redirect(url, 302);
}
// Cloudflare-specific options are in the cf object.
const options: CloudflareImageOptions = {
fit: 'scale-down',
@@ -90,6 +92,24 @@ export async function GET(request: NextRequest) {
return response;
} catch (error) {
// Redirect to the original image if resizing fails
return Response.redirect(url, 302);
return NextResponse.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;
}
@@ -1,11 +1,15 @@
import { TrackPageViewEvent } from '@/components/Insights';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData } from '@/lib/api';
import { getSiteData, getSpaceContentData } from '@/lib/api';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = getSiteContentPointer();
const { customization } = await getSiteData(pointer);
const pointer = await getSiteContentPointer();
const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer),
]);
const language = getSpaceLanguage(customization);
@@ -19,6 +23,9 @@ export default async function NotFound() {
</h2>
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
</div>
{/* Track the page not found as a page view */}
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
</div>
);
}
@@ -5,11 +5,10 @@ import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
import { PageClientLayout } from './PageClientLayout';
@@ -21,10 +20,13 @@ export const runtime = 'edge';
* Fetch and render a page.
*/
export default async function Page(props: {
params: PagePathParams;
searchParams: { fallback?: string };
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
}) {
const { params, searchParams } = props;
const { params: rawParams, searchParams: rawSearchParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
const {
content: contentPointer,
@@ -50,12 +52,12 @@ export default async function Page(props: {
if (pathname !== rawPathname) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(absoluteHref(pathname));
redirect(await getAbsoluteHref(pathname));
} else {
notFound();
}
} else if (getPagePath(pages, page) !== rawPathname) {
redirect(pageHref(pages, page, linksContext));
redirect(await getPageHref(pages, page, linksContext));
}
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -119,8 +121,12 @@ export default async function Page(props: {
);
}
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
const { customization } = await fetchPageData(params);
export async function generateViewport({
params,
}: {
params: Promise<PagePathParams>;
}): Promise<Viewport> {
const { customization } = await fetchPageData(await params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
@@ -134,12 +140,12 @@ export async function generateMetadata({
params,
searchParams,
}: {
params: PagePathParams;
searchParams: { fallback?: string };
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
}): Promise<Metadata> {
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: params,
searchParams,
pagePathParams: await params,
searchParams: await searchParams,
});
if (!page) {
@@ -153,16 +159,17 @@ export async function generateMetadata({
description: page.description ?? '',
alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: absoluteHref(getPagePath(pages, page), true).replace(/\/+$/, ''),
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
},
openGraph: {
images: [
customization.socialPreview.url ??
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
],
},
robots:
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
(await isSpaceIndexable({ space, site: site ?? null })) &&
isPageIndexable(ancestors, page)
? 'index, follow'
: 'noindex, nofollow',
};
@@ -186,7 +193,7 @@ async function getPageDataWithFallback(args: {
const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(pageHref(pages, rootPage?.page));
redirect(await getPageHref(pages, rootPage?.page));
}
page = rootPage?.page;
@@ -1,6 +1,7 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import React from 'react';
import * as ReactDOM from 'react-dom';
@@ -12,7 +13,7 @@ import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { absoluteHref, baseUrl } from '@/lib/links';
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
import { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
@@ -28,7 +29,7 @@ export const runtime = 'edge';
export default async function ContentLayout(props: { children: React.ReactNode }) {
const { children } = props;
const nonce = getContentSecurityPolicyNonce();
const nonce = await getContentSecurityPolicyNonce();
const {
content,
space,
@@ -42,7 +43,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
sections,
} = await fetchContentData();
ReactDOM.preconnect(api().client.endpoint);
const apiCtx = await api();
ReactDOM.preconnect(apiCtx.client.endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
@@ -54,47 +56,51 @@ export default async function ContentLayout(props: { children: React.ReactNode }
});
});
const queryStringTheme = await getQueryStringTheme();
return (
<ClientContexts
nonce={nonce}
forcedTheme={
getQueryStringTheme() ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
>
<SpaceLayout
space={space}
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
<NuqsAdapter>
<ClientContexts
nonce={nonce}
forcedTheme={
queryStringTheme ??
(customization.themes.toggeable ? undefined : customization.themes.default)
}
>
{children}
</SpaceLayout>
<SpaceLayout
space={space}
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
>
{children}
</SpaceLayout>
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} nonce={nonce} />
))}
</>
) : null}
{scripts.length > 0 ? (
<>
<LoadIntegrations />
{scripts.map(({ script }) => (
<script key={script} async src={script} nonce={nonce} />
))}
</>
) : null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
</React.Suspense>
) : null}
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
<React.Suspense fallback={null}>
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
</React.Suspense>
) : null}
<RocketLoaderDetector nonce={nonce} />
<RocketLoaderDetector nonce={nonce} />
<AdminToolbar space={space} content={content} />
</ClientContexts>
<AdminToolbar space={space} content={content} />
</ClientContexts>
</NuqsAdapter>
);
}
@@ -116,34 +122,34 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(baseUrl()),
metadataBase: new URL(await getBaseUrl()),
icons: {
icon: [
{
url:
customIcon?.light ??
absoluteHref('~gitbook/icon?size=small&theme=light', true),
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url:
customIcon?.dark ??
absoluteHref('~gitbook/icon?size=small&theme=dark', true),
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
],
},
robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow',
};
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
function getQueryStringTheme() {
const headersList = headers();
async function getQueryStringTheme() {
const headersList = await headers();
const queryStringTheme = headersList.get('x-gitbook-theme');
if (!queryStringTheme) {
return null;
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server';
import { getSpace, getSite } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
@@ -11,7 +11,7 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const [site, space] = await Promise.all([
getSite(pointer.organizationId, pointer.siteId),
getSpace(pointer.spaceId, pointer.siteShareKey),
@@ -20,8 +20,8 @@ export async function GET(req: NextRequest) {
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...(isSpaceIndexable({ space, site })
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
...((await isSpaceIndexable({ space, site }))
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
const content = lines.join('\n');
@@ -3,7 +3,7 @@ import jsontoxml from 'jsontoxml';
import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { isPageIndexable } from '@/lib/seo';
@@ -14,31 +14,33 @@ export const runtime = 'edge';
* Generate a sitemap.xml for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = getSiteContentPointer();
const pointer = await 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 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 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],
}
: {}),
},
};
});
return {
url: {
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
priority: normalizedPriority,
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
}),
);
const xml = jsontoxml(
[
@@ -35,7 +35,7 @@ export async function GET(req: NextRequest) {
const options = getOptions(req.url);
const size = SIZES[options.size];
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const spaceId = pointer.spaceId;
const [space, { customization }] = await Promise.all([
@@ -5,7 +5,7 @@ import { NextRequest } from 'next/server';
import colorContrast from 'postcss-color-contrast/js';
import React from 'react';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
@@ -16,8 +16,8 @@ export const runtime = 'edge';
/**
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
const { space, page, customization, site } = await fetchPageData(params);
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
const { space, page, customization, site } = await fetchPageData(await params);
if (customization.socialPreview.url) {
// If user configured a custom social preview, we redirect to it.
@@ -51,8 +51,10 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
};
const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
const [gridWhite, gridBlack] = await Promise.all([
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
]);
let gridAsset = useLightTheme ? gridBlack : gridWhite;
@@ -90,7 +92,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
break;
}
const favicon = function () {
const favicon = await (async () => {
if ('icon' in customization.favicon)
return (
<img
@@ -107,19 +109,12 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
</span>
);
return (
<img
src={absoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
)}
alt="Icon"
width={40}
height={40}
tw={tcls('mr-4')}
/>
const src = await getAbsoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
);
};
return <img src={src} alt="Icon" width={40} height={40} tw={tcls('mr-4')} />;
})();
return new ImageResponse(
(
@@ -165,7 +160,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
/>
) : (
<div tw={tcls('flex')}>
{favicon()}
{favicon}
<h3 tw={tcls('text-4xl', 'my-0')}>
{getContentTitle(space, customization, site ?? null)}
</h3>
+7 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { captureException } from '@sentry/nextjs';
import { captureException, withScope } from '@sentry/nextjs';
import React from 'react';
import { Button } from '@/components/primitives/Button';
@@ -15,7 +15,12 @@ export default function ErrorPage(props: {
const language = useLanguage();
React.useEffect(() => {
captureException(error);
withScope((scope) => {
if ('_componentStack' in error && error._componentStack) {
scope.setExtra('componentStack', error._componentStack);
}
captureException(error);
});
}, [error]);
return (
+1 -1
View File
@@ -23,7 +23,7 @@ export interface PageIdParams {
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = getSiteContentPointer();
const content = await getSiteContentPointer();
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([
+1 -1
View File
@@ -9,7 +9,7 @@ import { getSiteContentPointer } from '@/lib/pointer';
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteContentPointer();
const pointer = await getSiteContentPointer();
const { customization } = await getSiteData(pointer);
return (
@@ -12,10 +12,10 @@ import { getSiteOrSpacePointerForPDF } from './pointer';
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const pointer = getSiteOrSpacePointerForPDF();
const pointer = await getSiteOrSpacePointerForPDF();
const { customization } = await ('siteId' in pointer
? getSiteData(pointer)
: getSpaceLayoutData(pointer.spaceId));
: getSpaceLayoutData());
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -25,9 +25,9 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
/**
* Fetch all the layout data about a space at once.
*/
async function getSpaceLayoutData(spaceId: string) {
async function getSpaceLayoutData() {
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(spaceId),
getSpaceCustomization(),
[] as SpaceIntegrationScript[],
]);
@@ -23,7 +23,7 @@ import {
getSpaceContentData,
getSiteData,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -39,10 +39,10 @@ const DEFAULT_LIMIT = 100;
export const runtime = 'edge';
export async function generateMetadata(): Promise<Metadata> {
const pointer = getSiteOrSpacePointerForPDF();
const pointer = await getSiteOrSpacePointerForPDF();
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
]);
return {
@@ -54,19 +54,21 @@ export async function generateMetadata(): Promise<Metadata> {
/**
* Render a space as a standalone HTML page that can be printed as a PDF.
*/
export default async function PDFHTMLOutput(props: { searchParams: { [key: string]: string } }) {
const pointer = getSiteOrSpacePointerForPDF();
export default async function PDFHTMLOutput(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const pointer = await getSiteOrSpacePointerForPDF();
const searchParams = new URLSearchParams(props.searchParams);
const searchParams = new URLSearchParams(await props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params
let currentPDFUrl = absoluteHref('~gitbook/pdf', true);
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
currentPDFUrl += '?' + searchParams.toString();
// Load the content,
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(pointer.spaceId),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]);
const language = getSpaceLanguage(customization);
@@ -74,7 +76,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
// Compute the pages to render
const { pages, total } = selectPages(rootPages, pdfParams);
const pageIds = pages.map(
({ page }) => [page.id, pagePDFContainerId(page)] as [string, string],
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string],
);
const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id),
@@ -86,7 +88,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a
title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? absoluteHref('')}
href={pdfParams.back ?? (await getAbsoluteHref(''))}
className={tcls(
'flex',
'flex-row',
@@ -156,7 +158,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
<React.Suspense
key={page.id}
fallback={
<PrintPage id={pagePDFContainerId(page)}>
<PrintPage id={getPagePDFContainerId(page)}>
<p>Loading...</p>
</PrintPage>
}
@@ -201,7 +203,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props;
return (
<PrintPage id={pagePDFContainerId(page)}>
<PrintPage id={getPagePDFContainerId(page)}>
<div
className={tcls(
'break-before-page',
@@ -229,7 +231,7 @@ async function PDFPageDocument(props: {
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
return (
<PrintPage id={pagePDFContainerId(page)}>
<PrintPage id={getPagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
{page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
@@ -248,7 +250,7 @@ async function PDFPageDocument(props: {
},
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
getId: (id) => pagePDFContainerId(page, id),
getId: (id) => getPagePDFContainerId(page, id),
}}
/>
) : null}
@@ -8,7 +8,9 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
*
* This function returns the pointer depending on the context.
*/
export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
export async function getSiteOrSpacePointerForPDF(): Promise<
SiteContentPointer | SpaceContentPointer
> {
try {
return getSiteContentPointer();
} catch (error) {
+18 -6
View File
@@ -1,13 +1,10 @@
// @ts-ignore
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
import { withSentry } from '@sentry/cloudflare';
import { withMiddlewareHeadersStorage } from './lib/middleware';
/**
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
*/
export default {
const exportedHandler = {
async fetch(request, env, ctx) {
const response = await withMiddlewareHeadersStorage(() =>
nextOnPagesHandler.fetch(request, env, ctx),
@@ -15,4 +12,19 @@ export default {
return response;
},
} as ExportedHandler<{ ASSETS: Fetcher }>;
} satisfies ExportedHandler<{ ASSETS: Fetcher }>;
/**
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
*/
export default withSentry(
(env) => ({
dsn: env.SENTRY_DSN,
release: env.SENTRY_RELEASE,
environment: env.SENTRY_ENVIRONMENT,
tracesSampleRate: 0,
}),
// @ts-ignore
exportedHandler,
);
@@ -44,6 +44,10 @@ export function Ad({
return;
}
if (typeof IntersectionObserver === 'undefined') {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
@@ -8,7 +8,11 @@ import { AdItem } from './types';
/**
* Classic rendering for an ad.
*/
export function AdClassicRendering({ ad }: { ad: AdItem }) {
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
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 (
<a
className={tcls(
@@ -28,25 +32,18 @@ export function AdClassicRendering({ ad }: { ad: AdItem }) {
rel="sponsored noopener"
target="_blank"
>
{'smallImage' in ad ? (
{smallImgSrc && 'smallImage' in ad ? (
<div>
<img
alt="Ads logo"
className={tcls('rounded-md')}
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
/>
<img alt="Ads logo" className={tcls('rounded-md')} src={smallImgSrc} />
</div>
) : (
) : logoSrc && 'logo' in ad ? (
<div
className={tcls('px-6', 'py-4', 'rounded-md')}
style={{ backgroundColor: ad.backgroundColor }}
>
<img
alt="Ads logo"
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
/>
<img alt="Ads logo" src={logoSrc} />
</div>
)}
) : null}
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
@@ -9,8 +9,8 @@ import { AdCover } from './types';
/**
* Cover rendering for an ad.
*/
export function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
return (
<a
@@ -40,7 +40,7 @@ interface FetchPlaceholderAdOptions {
export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd();
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
@@ -64,7 +64,7 @@ async function fetchAd({
placement,
ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
const { ip, userAgent } = getUserAgentAndIp();
const { ip, userAgent } = await getUserAgentAndIp();
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
@@ -86,8 +86,8 @@ async function fetchAd({
return null;
}
function getPlaceholderAd(): { ad: AdItem; ip: string } {
const { ip } = getUserAgentAndIp();
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
const { ip } = await getUserAgentAndIp();
return {
ad: {
@@ -119,8 +119,8 @@ function getPlaceholderAd(): { ad: AdItem; ip: string } {
};
}
function getUserAgentAndIp() {
const headersSet = headers();
async function getUserAgentAndIp() {
const headersSet = await headers();
const ip =
headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ??
@@ -6,9 +6,10 @@ import * as React from 'react';
import { Button } from '@/components/primitives';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
import { tcls } from '@/lib/tailwind';
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
/**
* Toast to accept or reject the use of cookies.
*/
@@ -1,6 +1,6 @@
import { DocumentBlockContentRef } from '@gitbook/api';
import { Card, Emoji } from '@/components/primitives';
import { Card } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { ResolvedContentRef } from '@/lib/references';
@@ -34,6 +34,10 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
href={resolved.href}
title={resolved.text}
style={style}
insights={{
target: block.data.ref,
position: 'content',
}}
/>
);
}
@@ -48,7 +52,7 @@ async function SpaceRefCard(
return null;
}
const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
const { customization: spaceCustomization } = await getSpaceCustomization();
const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
@@ -5,10 +5,8 @@ import {
createCssVariablesTheme,
HighlighterGeneric,
} from 'shiki/core';
import { loadWasm, createOnigurumaEngine } from 'shiki/engine/oniguruma';
import { bundledLanguages } from 'shiki/langs';
// @ts-ignore - onigWasm is a Wasm module
import onigWasm from 'shiki/onig.wasm?module';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import { BundledLanguage, bundledLanguages } from 'shiki/langs';
import { asyncMutexFunction, singleton } from '@/lib/async';
import { getNodeText } from '@/lib/document';
@@ -81,18 +79,35 @@ export async function highlight(block: DocumentBlockCode): Promise<HighlightLine
});
}
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): keyof typeof bundledLanguages | null {
// @ts-ignore
const lang = bundledLanguages[syntax];
if (!lang) {
return null;
function getLanguageForSyntax(syntax: string): BundledLanguage | null {
// Normalize the syntax to lowercase.
syntax = syntax.toLowerCase();
// Check if the syntax is a bundled language.
if (checkIsBundledLanguage(syntax)) {
return syntax;
}
// @ts-ignore
return syntax;
// Check if there is a valid alias for the syntax.
const alias = syntaxAliases[syntax];
if (alias && checkIsBundledLanguage(alias)) {
return alias;
}
return null;
}
/**
@@ -299,7 +314,10 @@ function cleanupLine(line: string): string {
const createHighlighter = createdBundledHighlighter<any, any>({
langs: bundledLanguages,
themes: {},
engine: () => createOnigurumaEngine(import('shiki/wasm')),
engine: () =>
createJavaScriptRegexEngine({
forgiving: true,
}),
});
/**
@@ -308,14 +326,6 @@ const createHighlighter = createdBundledHighlighter<any, any>({
*/
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: [],
@@ -13,7 +13,8 @@ import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const { block, context, ...otherProps } = props;
const nonce = headers().get('x-nonce') || undefined;
const headersList = await headers();
const nonce = headersList.get('x-nonce') || undefined;
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
@@ -6,6 +6,7 @@ import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
import { Caption } from './Caption';
import { FileIcon } from './FileIcon';
import { Link } from '../primitives';
export async function File(props: BlockProps<DocumentBlockFile>) {
const { block, context } = props;
@@ -21,9 +22,13 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
return (
<Caption {...props} wrapperStyle={[]}>
<a
<Link
href={file.downloadURL}
download={file.name}
insights={{
target: block.data.ref,
position: 'content',
}}
className={tcls(
'group/file',
'flex',
@@ -77,7 +82,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
{contentType}
</div>
</div>
</a>
</Link>
</Caption>
);
}
@@ -22,6 +22,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
}
const isInLink = ancestorInlines.some((ancestor) => ancestor.type === 'link');
const sizes = await getImageSizes(size, src);
return (
/* Ensure images dont expand to the size of the container where this Image may be nested in. Now it's always nested in a size-restricted container */
@@ -32,7 +33,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
>
<Image
alt={inline.data.caption ?? ''}
sizes={await getImageSizes(size, src)}
sizes={sizes}
sources={{
light: {
src: src.href,
@@ -25,7 +25,11 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
return (
<Link
href={resolved.href}
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors "
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors"
insights={{
target: inline.data.ref,
position: 'content',
}}
>
<Inlines
context={context}
@@ -38,6 +38,8 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
'[&:is(h2)>div]:mt-0',
'[&:is(h3)>div]:mt-0',
'[&:is(h4)>div]:mt-0',
// Override the "mx-auto" class from UnwrappedBlocks
'mx-0',
)}
style="space-y-2 flex flex-col flex-1"
/>
@@ -135,7 +137,7 @@ function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.R
className={tcls(
textStyle.textSize,
textStyle.lineHeight,
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-dark/6 dark:text-light/5',
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-dark-4 dark:text-light-4',
)}
>
{props.children}
@@ -15,5 +15,15 @@ export async function Mention(props: InlineProps<DocumentInlineMention>) {
return null;
}
return <StyledLink href={resolved.href}>{resolved.text}</StyledLink>;
return (
<StyledLink
href={resolved.href}
insights={{
target: inline.data.ref,
position: 'content',
}}
>
{resolved.text}
</StyledLink>
);
}
@@ -1,4 +1,4 @@
import { DocumentBlockSwagger } from '@gitbook/api';
import { DocumentBlockOpenAPI } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { OpenAPIOperation } from '@gitbook/react-openapi';
import React from 'react';
@@ -16,7 +16,7 @@ import './scalar.css';
/**
* Render an OpenAPI block.
*/
export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
const { block, style } = props;
return (
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
@@ -27,7 +27,7 @@ export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
);
}
async function OpenAPIBody(props: BlockProps<DocumentBlockSwagger>) {
async function OpenAPIBody(props: BlockProps<DocumentBlockOpenAPI>) {
const { block, context } = props;
const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef);
@@ -1,6 +1,7 @@
import { ContentRef, DocumentTableViewCards } from '@gitbook/api';
import React from 'react';
import { Link } from '@/components/primitives';
import { Image } from '@/components/utils';
import { ClassValue, tcls } from '@/lib/tailwind';
@@ -153,17 +154,21 @@ export async function RecordCard(
'before:dark:ring-light/2',
] as ClassValue;
if (target) {
if (target && targetRef) {
return (
<a
<Link
href={target.href}
className={tcls(style, [
'hover:before:ring-dark/4',
'dark:hover:before:ring-light/4',
])}
insights={{
target: targetRef,
position: 'content',
}}
>
{body}
</a>
</Link>
);
}
@@ -1,4 +1,4 @@
import { ContentRef, DocumentBlockTable } from '@gitbook/api';
import { ContentRef, ContentRefUser, DocumentBlockTable } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import assertNever from 'assert-never';
@@ -148,6 +148,17 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
href={ref.href}
target="_blank"
style={['flex', 'flex-row', 'items-center', 'gap-2']}
insights={
ref.file
? {
target: {
kind: 'file',
file: ref.file.id,
},
position: 'content',
}
: undefined
}
>
{contentType === 'image' ? (
<Image
@@ -178,8 +189,9 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
</Tag>
);
case 'content-ref': {
const resolved = value
? await context.resolveContentRef(value as ContentRef, {
const contentRef = value ? (value as ContentRef) : null;
const resolved = contentRef
? await context.resolveContentRef(contentRef, {
resolveAnchorText: true,
iconStyle: ['mr-2', 'text-dark/6', 'dark:text-light/6'],
})
@@ -191,26 +203,51 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
>
{resolved?.icon ?? null}
{resolved ? (
<StyledLink href={resolved.href}>{resolved.text}</StyledLink>
<StyledLink
href={resolved.href}
insights={
contentRef
? {
target: contentRef,
position: 'content',
}
: undefined
}
>
{resolved.text}
</StyledLink>
) : null}
</Tag>
);
}
case 'users': {
const resolved = await Promise.all(
(value as string[]).map((userId) =>
context.resolveContentRef({
(value as string[]).map(async (userId) => {
const contentRef: ContentRefUser = {
kind: 'user',
user: userId,
}),
),
};
const resolved = await context.resolveContentRef(contentRef);
if (!resolved) {
return null;
}
return [contentRef, resolved] as const;
}),
);
return (
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
{resolved.filter(filterOutNullable).map((file, index) => (
<StyledLink key={index} href={file.href}>
{file.text}
{resolved.filter(filterOutNullable).map(([contentRef, resolved], index) => (
<StyledLink
key={index}
href={resolved.href}
insights={{
target: contentRef,
position: 'content',
}}
>
{resolved.text}
</StyledLink>
))}
</Tag>
@@ -27,7 +27,7 @@
}
.columnHeader {
@apply text-sm font-medium py-3 px-4 text-tint-900;
@apply text-sm font-medium py-2 px-4 text-tint-900;
}
:global(.dark) .columnHeader {
@@ -35,7 +35,7 @@
}
.row {
@apply flex border-tint-700/2;
@apply flex border-tint-700/2 hover:bg-tint-700/2 transition-colors;
}
:global(.dark) .row {
@@ -43,7 +43,7 @@
}
.rowGroup {
@apply flex flex-col border rounded-lg bg-tint-800/1 border-tint-700/2;
@apply flex flex-col border rounded-lg bg-tint-800/1 border-tint-700/2 mb-1;
}
:global(.dark) .rowGroup {
@@ -51,9 +51,13 @@
}
.cell {
@apply flex-1 align-middle border-dark/2 py-2 px-4 text-sm;
@apply flex-1 align-middle border-tint-700/2 py-2 px-4 text-sm relative;
}
.cell:not(:last-child) {
@apply border-r;
}
:global(.dark) .cell {
@apply border-light/2;
@apply border-tint-300/3;
}
@@ -1,11 +1,43 @@
'use client';
import React from 'react';
import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
import React, { useCallback, useMemo } from 'react';
import { useHash, useIsMounted } from '@/components/hooks';
import * as storage from '@/lib/local-storage';
import { ClassValue, tcls } from '@/lib/tailwind';
interface TabsState {
activeIds: {
[tabsBlockId: string]: string;
};
activeTitles: string[];
}
const defaultTabsState: TabsState = {
activeIds: {},
activeTitles: [],
};
let globalTabsState = storage.getItem('@gitbook/tabsState', defaultTabsState);
const listeners = new Set<() => void>();
function useTabsState() {
const subscribe = useCallback((callback: () => void) => {
listeners.add(callback);
return () => listeners.delete(callback);
}, []);
const getSnapshot = useCallback(() => globalTabsState, []);
const setTabsState = useCallback((updater: (previous: TabsState) => TabsState) => {
globalTabsState = updater(globalTabsState);
storage.setItem('@gitbook/tabsState', globalTabsState);
listeners.forEach((listener) => listener());
}, []);
const state = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [state, setTabsState] as const;
}
// How many titles are remembered:
const TITLES_MAX = 5;
@@ -14,7 +46,6 @@ export interface TabsItem {
title: string;
}
// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925
type SelectorMapper<Type> = {
[Property in keyof Type]: Type[Property];
};
@@ -42,18 +73,21 @@ export function DynamicTabs(
const { id, tabs, tabsBody, style } = props;
const hash = useHash();
const [tabsState, setTabsState] = useTabsState();
const activeState = useMemo(() => {
const input = { id, tabs };
return (
getTabBySelection(input, tabsState) ?? getTabByTitle(input, tabsState) ?? input.tabs[0]
);
}, [id, tabs, tabsState]);
const activeState = useRecoilValue(tabsActiveSelector({ id, tabs }));
// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
// To avoid issue with hydration, we only use the state from localStorage
// once the component has been mounted.
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
// and get stuck into an inconsistent state.
const mounted = useIsMounted();
const active = mounted ? activeState : tabs[0];
const setTabsState = useSetRecoilState(tabsAtom);
/**
* When clicking to select a tab, we:
* - mark this specific ID as selected
@@ -220,44 +254,6 @@ export function DynamicTabs(
);
}
const tabsAtom = atom<TabsState>({
key: 'tabsAtom',
default: {
activeIds: {},
activeTitles: [],
},
effects: [
// Persist the state to local storage
({ trigger, setSelf, onSet }) => {
if (typeof localStorage === 'undefined') {
return;
}
const localStorageKey = '@gitbook/tabsState';
if (trigger === 'get') {
const stored = localStorage.getItem(localStorageKey);
if (stored) {
setSelf(JSON.parse(stored));
}
}
onSet((newState) => {
localStorage.setItem(localStorageKey, JSON.stringify(newState));
});
},
],
});
const tabsActiveSelector = selectorFamily<TabsItem, SelectorMapper<TabsInput>>({
key: 'tabsActiveSelector',
get:
(input) =>
({ get }) => {
const state = get(tabsAtom);
return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0];
},
});
/**
* Get the ID for a tab button.
*/
@@ -73,7 +73,6 @@ function Code(props: MarkedLeafProps<DocumentMarkCode>) {
'py-[1px]',
'px-1.5',
'min-w-[1.625rem]',
'inline-flex',
'justify-center',
'items-center',
'leading-normal',
@@ -82,10 +81,8 @@ function Code(props: MarkedLeafProps<DocumentMarkCode>) {
'ring-dark/1',
'bg-dark/[0.06]',
'rounded',
'text-dark/8',
'dark:ring-light/1',
'dark:bg-light/1',
'dark:text-light/7',
// Text size is proportional to the font-size of the parent element
'text-[.875em]',
// We ensure that the code is not making the parent bigger, especially in headings
@@ -40,6 +40,10 @@ async function FooterLink(props: { link: CustomizationContentLink; context: Cont
'dark:text-light/8',
'dark:hover:text-light/9',
)}
insights={{
target: link.to,
position: 'footer',
}}
>
{link.title}
</Link>
@@ -1,72 +0,0 @@
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import React from 'react';
import { t } from '@/intl/server';
import { getSpaceLanguage } from '@/intl/server';
import { tcls } from '@/lib/tailwind';
import { HeaderLogo } from './HeaderLogo';
import { SpacesDropdown } from './SpacesDropdown';
import { SearchButton } from '../Search';
/**
* Header to display on top of the table of contents when the space has no header configured.
*/
export function CompactHeader(props: {
space: Space;
site: Site | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { space, spaces, site, customization } = props;
const isMultiVariants = site && spaces.length > 1;
return (
<div
className={tcls(
'hidden',
'pr-4',
'lg:flex',
'lg:flex-col',
'flex-wrap',
'lg:gap-x-5',
'gap-y-3',
'dark:shadow-light/1',
'justify-between',
)}
>
<div className={tcls('flex-grow-0', 'mt-5')}>
<HeaderLogo site={site} space={space} customization={customization} />
</div>
<div
className={tcls(
'flex-shrink-0',
'grow-0',
'md:grow',
'sm:max-w-xs',
'lg:mt-2',
'lg:mb-8',
'lg:max-w-full',
'justify-self-end',
)}
>
<React.Suspense fallback={null}>
<SearchButton>
<span className={tcls('flex-1')}>
{t(
getSpaceLanguage(customization),
customization.aiSearch.enabled ? 'search_or_ask' : 'search',
)}
</span>
</SearchButton>
</React.Suspense>
</div>
</div>
);
}
@@ -3,7 +3,7 @@ import { DetailedHTMLProps, HTMLAttributes, useId } from 'react';
import { ClassValue, tcls } from '@/lib/tailwind';
import { Link } from '../primitives';
import { Link, LinkInsightsProps } from '../primitives';
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
@@ -110,19 +110,22 @@ export function DropdownMenu(props: { children: React.ReactNode }) {
/**
* Menu item in a dropdown.
*/
export function DropdownMenuItem(props: {
href: string | null;
active?: boolean;
className?: ClassValue;
children: React.ReactNode;
}) {
const { children, active = false, href, className } = props;
export function DropdownMenuItem(
props: {
href: string | null;
active?: boolean;
className?: ClassValue;
children: React.ReactNode;
} & LinkInsightsProps,
) {
const { children, active = false, href, className, insights } = props;
if (href) {
return (
<Link
href={href}
prefetch={false}
insights={insights}
className={tcls(
'px-3 py-1 text-sm rounded straight-corners:rounded-sm',
active ? 'bg-primary/3 dark:bg-light/2 text-primary-600' : null,
@@ -95,7 +95,25 @@ export function Header(props: {
<HeaderLogo site={site} space={space} customization={customization} />
{!hasSiteSections && isMultiVariants ? (
<div className="z-20 shrink hidden sm:block">
<SpacesDropdown space={space} spaces={spaces} />
<SpacesDropdown
className={tcls(
!isCustomizationDefault &&
withTopHeader && [
'bg-header-link/2',
'dark:bg-header-link/2',
'text-header-link/8',
'dark:text-header-link/8',
'ring-1',
'ring-header-link/4',
'dark:ring-header-link/4',
'contrast-more:bg-header-background',
'contrast-more:text-header-link',
'contrast-more:ring-header-link',
],
)}
space={space}
spaces={spaces}
/>
</div>
) : null}
</div>
@@ -4,6 +4,7 @@ import {
CustomizationHeaderPreset,
SiteCustomizationSettings,
CustomizationHeaderItem,
ContentRef,
} from '@gitbook/api';
import assertNever from 'assert-never';
@@ -35,7 +36,7 @@ export async function HeaderLink(props: {
<Dropdown
className="shrink"
button={(buttonProps) => {
if (!target) {
if (!target || !link.to) {
return (
<HeaderItemDropdown
{...buttonProps}
@@ -47,6 +48,7 @@ export async function HeaderLink(props: {
return (
<HeaderLinkNavItem
{...buttonProps}
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={link.title}
@@ -65,12 +67,13 @@ export async function HeaderLink(props: {
);
}
if (!target) {
if (!target || !link.to) {
return null;
}
return (
<HeaderLinkNavItem
linkTarget={link.to}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={link.title}
@@ -81,6 +84,7 @@ export async function HeaderLink(props: {
}
export type HeaderLinkNavItemProps = {
linkTarget: ContentRef;
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
headerPreset: CustomizationHeaderPreset;
title: string;
@@ -89,14 +93,15 @@ export type HeaderLinkNavItemProps = {
} & DropdownButtonProps<HTMLElement>;
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
switch (props.linkStyle) {
const { linkStyle, ...rest } = props;
switch (linkStyle) {
case 'button-secondary':
case 'button-primary':
return <HeaderItemButton {...props} linkStyle={props.linkStyle} />;
return <HeaderItemButton {...rest} linkStyle={linkStyle} />;
case 'link':
return <HeaderItemLink {...props} />;
return <HeaderItemLink {...rest} />;
default:
assertNever(props.linkStyle);
assertNever(linkStyle);
}
}
@@ -105,7 +110,7 @@ function HeaderItemButton(
linkStyle: 'button-secondary' | 'button-primary';
},
) {
const { linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
const { linkTarget, linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
@@ -139,6 +144,10 @@ function HeaderItemButton(
),
}[linkStyle],
)}
insights={{
target: linkTarget,
position: 'header',
}}
{...rest}
>
{title}
@@ -158,10 +167,18 @@ function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset
);
}
function HeaderItemLink(props: HeaderLinkNavItemProps) {
const { headerPreset, title, isDropdown, href, ...rest } = props;
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
return (
<Link href={href} className={getHeaderLinkClassName({ headerPreset })} {...rest}>
<Link
href={href}
className={getHeaderLinkClassName({ headerPreset })}
insights={{
target: linkTarget,
position: 'header',
}}
{...rest}
>
<span className="truncate min-w-0">{title}</span>
{isDropdown ? <DropdownChevron /> : null}
</Link>
@@ -198,5 +215,9 @@ async function SubHeaderLink(props: {
return null;
}
return <DropdownMenuItem href={target.href}>{link.title}</DropdownMenuItem>;
return (
<DropdownMenuItem href={target.href} insights={{ target: link.to, position: 'header' }}>
{link.title}
</DropdownMenuItem>
);
}
@@ -76,7 +76,19 @@ async function MoreMenuLink(props: {
{'links' in link && link.links.length > 0 && (
<hr className="first:hidden border-t border-light-3 dark:border-dark-3 my-1 -mx-2" />
)}
<DropdownMenuItem href={target?.href ?? null}>{link.title}</DropdownMenuItem>
<DropdownMenuItem
href={target?.href ?? null}
insights={
link.to
? {
target: link.to,
position: 'header',
}
: undefined
}
>
{link.title}
</DropdownMenuItem>
{'links' in link
? link.links.map((subLink, index) => (
<MoreMenuLink key={index} {...props} link={subLink} />
@@ -1,5 +1,4 @@
import {
Collection,
CustomizationHeaderPreset,
CustomizationSettings,
Site,
@@ -7,9 +6,8 @@ import {
Space,
} from '@gitbook/api';
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
import { Image } from '@/components/utils';
import { absoluteHref } from '@/lib/links';
import { getAbsoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
@@ -26,12 +24,13 @@ interface HeaderLogoProps {
* Render the logo for a space using the customization settings.
*/
export function HeaderLogo(props: HeaderLogoProps) {
export async function HeaderLogo(props: HeaderLogoProps) {
const { customization } = props;
const href = await getAbsoluteHref('');
return (
<Link
href={absoluteHref('')}
href={href}
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
>
{customization.header.logo ? (
@@ -2,11 +2,13 @@
import { Icon } from '@gitbook/icons';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLanguage, tString } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useScrollListener } from '../hooks/useScrollListener';
const globalClassName = 'navigation-open';
/**
@@ -28,26 +30,20 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
}
};
const handleScroll = () => {
const windowRef = useRef(window);
useScrollListener(() => {
if (window.scrollY >= scrollDistance) {
setHasScrolled(true);
} else {
setHasScrolled(false);
}
};
}, windowRef);
// Close the navigation when navigating to a page
useEffect(() => {
document.body.classList.remove(globalClassName);
}, [pathname]);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return (
<button
{...props}
@@ -1,2 +1,2 @@
export * from './Header';
export * from './CompactHeader';
export * from './HeaderLogo';

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