mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 07:05:18 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de2d468677 | |||
| 7f50ad68bb | |||
| 3a759b14a4 | |||
| b5e2e3fa8d | |||
| fec48ce708 | |||
| 0784bf5df7 | |||
| f8f485ae25 | |||
| 3491bdc522 | |||
| 2cda8d4cad | |||
| 3fcbe46262 | |||
| 884ee7073f | |||
| 88397bf419 | |||
| 1138d5935b | |||
| 8276ba080e | |||
| 37d13d80f5 | |||
| 1c97536ab1 | |||
| ca8f028cc0 | |||
| 300f7bfe4b | |||
| 82dc9c40f7 | |||
| 44a20fe5ee | |||
| b950a64406 | |||
| c5f374f66f | |||
| ae99f87db0 | |||
| 5112e3e79b | |||
| c1e27ccacc | |||
| b0bd871997 | |||
| c30bc24fd6 | |||
| 648f0e9e84 | |||
| d2bc5672e0 | |||
| 5c87ec7c2f | |||
| d66c184ed2 | |||
| 12f25d8bfc | |||
| cbe61397a3 | |||
| deb8c54da3 | |||
| 5dab70fab4 | |||
| 665b6bed66 | |||
| f4a90defc3 | |||
| 46f63cbd55 | |||
| c77142a16f | |||
| eb7c22f52a | |||
| e86e51f06f | |||
| db74ea3001 | |||
| 99579ac29c | |||
| 2f6540e008 | |||
| 72c3b80529 | |||
| 8126a83c50 | |||
| 210c432b37 | |||
| f92e90603f | |||
| 14172791e9 | |||
| e4e2f524d4 | |||
| fc7b16f6a7 | |||
| 6ba3ae74d5 | |||
| c71d1598d8 | |||
| 5950657b0e | |||
| 0e1a48cd06 | |||
| 98245e5f20 | |||
| 0b6ddca981 | |||
| ea1468c892 | |||
| e8e64bf510 | |||
| aaf8daf0d7 | |||
| 8af1abc4e1 | |||
| 48ab59feb3 |
@@ -0,0 +1 @@
|
|||||||
|
1.1.40
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix empty sitemap
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Adapt code to pull token from customer backend generated custom cookies
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Log component stack in Sentry
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix pass Sentry release properly
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add support for sidebar background styles
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Ignore invalid API calls to `getSiteRedirectFromSource` API
|
||||||
|
|
||||||
|
To reduce the load on the API and also avoid errors.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Ignore errors from event flushing
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve emoji setup, align with GitBook app
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix an issue where the redirects of potentially malicious images were not going through.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Make cookies access safer
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve contrast of search box placeholder
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Simplify the `useHash` algo used.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Track events for site insights using the new dedicated API.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve colour contrast of list item decoration
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Set Sentry release
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': minor
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Revert scalar to 1.0.87 to mitigate an issue with ApiClientModalProvider
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-contentkit': minor
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Send redirectOnError param to getPublishedContent when token is pulled from cookie
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-contentkit': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix potential invalid URL error in react-contentkit
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Avoid error on fetch by passing a string URL
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Track clicks on links (header, footer, content) for site insights.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix code format overriding inline link styles
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix emojis display
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix two issues where pages would crash due Recoil not behaving correctly in RSC.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix dynamic tabs infinite loop
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Prevent codemirror from loading multiple versions in scalar
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix variant selector contrast for non-default themes
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'@gitbook/react-openapi': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Updated scalar depdenency
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix search loading state
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Sentry instrumentation
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Even safer localStorage
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Avoid errors on legacy browsers
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve readability of tables with hover style and vertical dividers
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Image optimization endpoint redirects to underlying image URL if the signature is not the latest.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix bullet list display on full size blocks
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix "Parser" language syntax highlighting
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix a vulnerability issue for images using an older version of the image signing parameter.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Track an event into site insights when visitor is opening the Scalar API client.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Upgrade Next.js to v15, upgrade Shiki and use JS RegExp engine
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve smoothness of scroll listener
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'gitbook': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Sentry instrumentation server-side
|
||||||
@@ -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
@@ -5,7 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
env:
|
env:
|
||||||
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
|
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to Cloudflare Pages
|
name: Deploy to Cloudflare Pages
|
||||||
@@ -22,10 +22,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -46,6 +44,7 @@ jobs:
|
|||||||
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
|
||||||
@@ -95,62 +94,14 @@ jobs:
|
|||||||
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
|
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 }})
|
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
|
||||||
edit-mode: replace
|
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:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Format
|
name: Format
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -162,10 +113,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -177,10 +126,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -191,16 +138,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Build (Open Source)
|
name: Build (Open Source)
|
||||||
env:
|
env:
|
||||||
NPMRC_FONT_AWESOME_TOKEN: ''
|
NPM_TOKEN_READONLY: ''
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- run: bun run build
|
- run: bun run build
|
||||||
@@ -210,10 +155,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -34,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:
|
||||||
@@ -45,10 +43,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Setup bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: ./.github/composite/setup-bun
|
||||||
with:
|
|
||||||
bun-version: 1.1.18
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
[install.scopes]
|
[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
@@ -7,13 +7,12 @@
|
|||||||
"turbo": "^2.1.2"
|
"turbo": "^2.1.2"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.1.18",
|
"packageManager": "bun@1.1.18",
|
||||||
"patchedDependencies": {
|
"overrides": {
|
||||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
|
"@codemirror/state": "6.4.1"
|
||||||
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev --ui=stream",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:cloudflare": "turbo run build:cloudflare",
|
"build:cloudflare": "turbo run build:cloudflare",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
@@ -31,5 +30,9 @@
|
|||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"wrangler": "3.82.0"
|
"wrangler": "^3.100.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "wrangler types --experimental-include-runtime",
|
"generate": "wrangler types --experimental-include-runtime",
|
||||||
|
|||||||
@@ -6,20 +6,29 @@ import {
|
|||||||
CustomizationHeaderPreset,
|
CustomizationHeaderPreset,
|
||||||
CustomizationIconsStyle,
|
CustomizationIconsStyle,
|
||||||
CustomizationLocale,
|
CustomizationLocale,
|
||||||
|
CustomizationSidebarBackgroundStyle,
|
||||||
|
CustomizationSidebarListStyle,
|
||||||
CustomizationThemeMode,
|
CustomizationThemeMode,
|
||||||
SiteCustomizationSettings,
|
SiteCustomizationSettings,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import { test, expect, Page } from '@playwright/test';
|
import { test, expect, Page, BrowserContext } from '@playwright/test';
|
||||||
import deepMerge from 'deepmerge';
|
import deepMerge from 'deepmerge';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import { DeepPartial } from 'ts-essentials';
|
import { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getVisitorAuthCookieName,
|
||||||
|
getVisitorAuthCookieValue,
|
||||||
|
VISITOR_TOKEN_COOKIE,
|
||||||
|
} from '@/lib/visitor-token';
|
||||||
|
|
||||||
import { getContentTestURL } from '../tests/utils';
|
import { getContentTestURL } from '../tests/utils';
|
||||||
|
|
||||||
interface Test {
|
interface Test {
|
||||||
name: string;
|
name: string;
|
||||||
url: string; // URL to visit for testing
|
url: string; // URL to visit for testing
|
||||||
|
cookies?: Parameters<BrowserContext['addCookies']>[0];
|
||||||
run?: (page: Page) => Promise<unknown>; // The test to run
|
run?: (page: Page) => Promise<unknown>; // The test to run
|
||||||
fullPage?: boolean; // Whether the test should be fullscreened during testing
|
fullPage?: boolean; // Whether the test should be fullscreened during testing
|
||||||
screenshot?: false; // Should a screenshot be stored
|
screenshot?: false; // Should a screenshot be stored
|
||||||
@@ -51,6 +60,11 @@ const allThemePresets: CustomizationHeaderPreset[] = [
|
|||||||
CustomizationHeaderPreset.Custom,
|
CustomizationHeaderPreset.Custom,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [
|
||||||
|
CustomizationSidebarBackgroundStyle.Default,
|
||||||
|
CustomizationSidebarBackgroundStyle.Filled,
|
||||||
|
];
|
||||||
|
|
||||||
async function waitForCookiesDialog(page: Page) {
|
async function waitForCookiesDialog(page: Page) {
|
||||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
||||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
const accept = dialog.getByRole('button', { name: 'Accept' });
|
||||||
@@ -536,40 +550,47 @@ const testCases: TestsCase[] = [
|
|||||||
}),
|
}),
|
||||||
run: waitForCookiesDialog,
|
run: waitForCookiesDialog,
|
||||||
},
|
},
|
||||||
...allThemePresets.flatMap((preset) => ({
|
// Theme-specific tests
|
||||||
name: `With tint - Preset ${preset} - Theme ${theme}`,
|
...allThemePresets.flatMap((preset) => [
|
||||||
url: getCustomizationURL({
|
...allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||||||
styling: {
|
name: `With tint - Preset ${preset} - Sidebar ${sidebarStyle} - Theme ${theme}`,
|
||||||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
url: getCustomizationURL({
|
||||||
},
|
styling: {
|
||||||
header: {
|
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||||||
preset,
|
sidebar: {
|
||||||
...(preset === CustomizationHeaderPreset.Custom
|
background: sidebarStyle,
|
||||||
? {
|
list: CustomizationSidebarListStyle.Default,
|
||||||
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',
|
header: {
|
||||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
preset,
|
||||||
style: 'button-primary',
|
...(preset === CustomizationHeaderPreset.Custom
|
||||||
},
|
? {
|
||||||
],
|
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
|
||||||
},
|
linkColor: { light: '#4DDE98', dark: '#0C693D' },
|
||||||
themes: {
|
}
|
||||||
default: theme,
|
: {}),
|
||||||
toggeable: false,
|
links: [
|
||||||
},
|
{
|
||||||
}),
|
title: 'Secondary button',
|
||||||
run: waitForCookiesDialog,
|
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}`,
|
name: `With tint - Legacy background match - Theme ${theme}`,
|
||||||
url: getCustomizationURL({
|
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',
|
name: 'Languages',
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
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',
|
name: 'Tables',
|
||||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||||
@@ -1112,9 +1330,18 @@ for (const testCase of testCases) {
|
|||||||
test.describe(testCase.name, () => {
|
test.describe(testCase.name, () => {
|
||||||
for (const testEntry of testCase.tests) {
|
for (const testEntry of testCase.tests) {
|
||||||
const testFn = testEntry.only ? test.only : test;
|
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 contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
||||||
const url = getContentTestURL(contentUrl.toString(), 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);
|
await page.goto(url);
|
||||||
if (testEntry.run) {
|
if (testEntry.run) {
|
||||||
await testEntry.run(page);
|
await testEntry.run(page);
|
||||||
@@ -1152,6 +1379,10 @@ function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): s
|
|||||||
font: CustomizationFont.Inter,
|
font: CustomizationFont.Inter,
|
||||||
background: CustomizationBackground.Plain,
|
background: CustomizationBackground.Plain,
|
||||||
icons: CustomizationIconsStyle.Regular,
|
icons: CustomizationIconsStyle.Regular,
|
||||||
|
sidebar: {
|
||||||
|
background: CustomizationSidebarBackgroundStyle.Default,
|
||||||
|
list: CustomizationSidebarListStyle.Default,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
internationalization: {
|
internationalization: {
|
||||||
locale: CustomizationLocale.En,
|
locale: CustomizationLocale.En,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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,
|
||||||
@@ -67,19 +68,15 @@ module.exports = withSentryConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
silent: true,
|
release: process.env.SENTRY_RELEASE,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "^0.81.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:*",
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"@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-popover": "^1.0.7",
|
"@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",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
@@ -41,18 +42,17 @@
|
|||||||
"katex": "^0.16.9",
|
"katex": "^0.16.9",
|
||||||
"mathjax": "^3.2.2",
|
"mathjax": "^3.2.2",
|
||||||
"memoizee": "^0.4.15",
|
"memoizee": "^0.4.15",
|
||||||
"next": "14.2.15",
|
"next": "15.1.3",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nuqs": "^1.17.4",
|
"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",
|
"postcss-color-contrast": "^1.1.0",
|
||||||
"react": "18.3.1",
|
"react": "19.0.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.0.0",
|
||||||
"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",
|
||||||
@@ -60,16 +60,17 @@
|
|||||||
"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.23.1",
|
"shiki": "^1.26.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",
|
||||||
"url-join": "^5.0.0"
|
"url-join": "^5.0.0",
|
||||||
|
"usehooks-ts": "^3.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^2.0.0",
|
"@argos-ci/playwright": "^2.0.0",
|
||||||
"@cloudflare/next-on-pages": "^1.13.5",
|
"@cloudflare/next-on-pages": "1.13.7",
|
||||||
"@cloudflare/workers-types": "^4.20240725.0",
|
"@cloudflare/workers-types": "^4.20241230.0",
|
||||||
"@playwright/test": "^1.42.1",
|
"@playwright/test": "^1.42.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/jsontoxml": "^1.0.5",
|
"@types/jsontoxml": "^1.0.5",
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
import {
|
import * as Sentry from '@sentry/nextjs';
|
||||||
BrowserClient,
|
|
||||||
makeFetchTransport,
|
|
||||||
defaultStackParser,
|
|
||||||
getCurrentScope,
|
|
||||||
} from '@sentry/nextjs';
|
|
||||||
|
|
||||||
const dsn = process.env.SENTRY_DSN;
|
const dsn = process.env.SENTRY_DSN;
|
||||||
if (dsn) {
|
if (dsn) {
|
||||||
// To tree shake default integrations that we don't use
|
Sentry.init({
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
|
|
||||||
const client = new BrowserClient({
|
|
||||||
debug: false,
|
|
||||||
dsn,
|
dsn,
|
||||||
integrations: [],
|
// Disable tracing as it creates additional requests in an env where subrequests are limited.
|
||||||
sampleRate: 0.1,
|
tracesSampleRate: 0,
|
||||||
enableTracing: false,
|
|
||||||
beforeSendTransaction: () => {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
transport: makeFetchTransport,
|
|
||||||
stackParser: defaultStackParser,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
getCurrentScope().setClient(client);
|
|
||||||
client.init();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 {
|
import {
|
||||||
|
CURRENT_SIGNATURE_VERSION,
|
||||||
|
isSignatureVersion,
|
||||||
|
SignatureVersion,
|
||||||
verifyImageSignature,
|
verifyImageSignature,
|
||||||
resizeImage,
|
} from '@/lib/image-signatures';
|
||||||
CloudflareImageOptions,
|
import { resizeImage, CloudflareImageOptions, checkIsSizableImageURL } from '@/lib/images';
|
||||||
imagesResizingSignVersion,
|
|
||||||
checkIsSizableImageURL,
|
|
||||||
} from '@/lib/images';
|
|
||||||
import { parseImageAPIURL } from '@/lib/urls';
|
import { parseImageAPIURL } from '@/lib/urls';
|
||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
@@ -20,12 +20,15 @@ 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
|
||||||
@@ -35,17 +38,16 @@ export async function GET(request: NextRequest) {
|
|||||||
return new Response('Invalid url parameter', { status: 400 });
|
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
|
// Verify the signature
|
||||||
const verified = await verifyImageSignature(url, { signature });
|
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
|
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.
|
// Cloudflare-specific options are in the cf object.
|
||||||
const options: CloudflareImageOptions = {
|
const options: CloudflareImageOptions = {
|
||||||
fit: 'scale-down',
|
fit: 'scale-down',
|
||||||
@@ -90,6 +92,24 @@ 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 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 { getSpaceLanguage, t } from '@/intl/server';
|
||||||
import { getSiteData } from '@/lib/api';
|
import { getSiteData, getSpaceContentData } from '@/lib/api';
|
||||||
import { getSiteContentPointer } from '@/lib/pointer';
|
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 = getSiteContentPointer();
|
const pointer = await getSiteContentPointer();
|
||||||
const { customization } = await getSiteData(pointer);
|
const [{ space }, { customization }] = await Promise.all([
|
||||||
|
getSpaceContentData(pointer, pointer.siteShareKey),
|
||||||
|
getSiteData(pointer),
|
||||||
|
]);
|
||||||
|
|
||||||
const language = getSpaceLanguage(customization);
|
const language = getSpaceLanguage(customization);
|
||||||
|
|
||||||
@@ -19,6 +23,9 @@ export default async function NotFound() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
|
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Track the page not found as a page view */}
|
||||||
|
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ 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, absoluteHref, pageHref } from '@/lib/links';
|
import { PageHrefContext, getAbsoluteHref, getPageHref } 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';
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
import { PageClientLayout } from './PageClientLayout';
|
import { PageClientLayout } from './PageClientLayout';
|
||||||
@@ -21,10 +20,13 @@ export const runtime = 'edge';
|
|||||||
* Fetch and render a page.
|
* Fetch and render a page.
|
||||||
*/
|
*/
|
||||||
export default async function Page(props: {
|
export default async function Page(props: {
|
||||||
params: PagePathParams;
|
params: Promise<PagePathParams>;
|
||||||
searchParams: { fallback?: string };
|
searchParams: Promise<{ fallback?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { params, searchParams } = props;
|
const { params: rawParams, searchParams: rawSearchParams } = props;
|
||||||
|
|
||||||
|
const params = await rawParams;
|
||||||
|
const searchParams = await rawSearchParams;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
content: contentPointer,
|
content: contentPointer,
|
||||||
@@ -50,12 +52,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(absoluteHref(pathname));
|
redirect(await getAbsoluteHref(pathname));
|
||||||
} else {
|
} else {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
} else if (getPagePath(pages, page) !== rawPathname) {
|
} else if (getPagePath(pages, page) !== rawPathname) {
|
||||||
redirect(pageHref(pages, page, linksContext));
|
redirect(await getPageHref(pages, page, linksContext));
|
||||||
}
|
}
|
||||||
|
|
||||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
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> {
|
export async function generateViewport({
|
||||||
const { customization } = await fetchPageData(params);
|
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
|
||||||
@@ -134,12 +140,12 @@ export async function generateMetadata({
|
|||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: PagePathParams;
|
params: Promise<PagePathParams>;
|
||||||
searchParams: { fallback?: string };
|
searchParams: Promise<{ 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: params,
|
pagePathParams: await params,
|
||||||
searchParams,
|
searchParams: await searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -153,16 +159,17 @@ 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: absoluteHref(getPagePath(pages, page), true).replace(/\/+$/, ''),
|
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
images: [
|
images: [
|
||||||
customization.socialPreview.url ??
|
customization.socialPreview.url ??
|
||||||
absoluteHref(`~gitbook/ogimage/${page.id}`, true),
|
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
robots:
|
robots:
|
||||||
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
|
(await isSpaceIndexable({ space, site: site ?? null })) &&
|
||||||
|
isPageIndexable(ancestors, page)
|
||||||
? 'index, follow'
|
? 'index, follow'
|
||||||
: 'noindex, nofollow',
|
: 'noindex, nofollow',
|
||||||
};
|
};
|
||||||
@@ -186,7 +193,7 @@ async function getPageDataWithFallback(args: {
|
|||||||
const rootPage = resolveFirstDocument(pages, []);
|
const rootPage = resolveFirstDocument(pages, []);
|
||||||
|
|
||||||
if (redirectOnFallback && rootPage?.page) {
|
if (redirectOnFallback && rootPage?.page) {
|
||||||
redirect(pageHref(pages, rootPage?.page));
|
redirect(await getPageHref(pages, rootPage?.page));
|
||||||
}
|
}
|
||||||
|
|
||||||
page = rootPage?.page;
|
page = rootPage?.page;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CustomizationThemeMode } from '@gitbook/api';
|
import { CustomizationThemeMode } from '@gitbook/api';
|
||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
|
|
||||||
@@ -12,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 { absoluteHref, baseUrl } from '@/lib/links';
|
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
|
||||||
import { isSpaceIndexable } from '@/lib/seo';
|
import { isSpaceIndexable } from '@/lib/seo';
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export const runtime = 'edge';
|
|||||||
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 = getContentSecurityPolicyNonce();
|
const nonce = await getContentSecurityPolicyNonce();
|
||||||
const {
|
const {
|
||||||
content,
|
content,
|
||||||
space,
|
space,
|
||||||
@@ -42,7 +43,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
|||||||
sections,
|
sections,
|
||||||
} = await fetchContentData();
|
} = await fetchContentData();
|
||||||
|
|
||||||
ReactDOM.preconnect(api().client.endpoint);
|
const apiCtx = await api();
|
||||||
|
ReactDOM.preconnect(apiCtx.client.endpoint);
|
||||||
if (assetsDomain) {
|
if (assetsDomain) {
|
||||||
ReactDOM.preconnect(assetsDomain);
|
ReactDOM.preconnect(assetsDomain);
|
||||||
}
|
}
|
||||||
@@ -54,47 +56,51 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryStringTheme = await getQueryStringTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientContexts
|
<NuqsAdapter>
|
||||||
nonce={nonce}
|
<ClientContexts
|
||||||
forcedTheme={
|
nonce={nonce}
|
||||||
getQueryStringTheme() ??
|
forcedTheme={
|
||||||
(customization.themes.toggeable ? undefined : customization.themes.default)
|
queryStringTheme ??
|
||||||
}
|
(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}
|
|
||||||
>
|
>
|
||||||
{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 ? (
|
{scripts.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<LoadIntegrations />
|
<LoadIntegrations />
|
||||||
{scripts.map(({ script }) => (
|
{scripts.map(({ script }) => (
|
||||||
<script key={script} async src={script} nonce={nonce} />
|
<script key={script} async src={script} nonce={nonce} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
|
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
|
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<RocketLoaderDetector nonce={nonce} />
|
<RocketLoaderDetector nonce={nonce} />
|
||||||
|
|
||||||
<AdminToolbar space={space} content={content} />
|
<AdminToolbar space={space} content={content} />
|
||||||
</ClientContexts>
|
</ClientContexts>
|
||||||
|
</NuqsAdapter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,34 +122,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(baseUrl()),
|
metadataBase: new URL(await getBaseUrl()),
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{
|
||||||
url:
|
url:
|
||||||
customIcon?.light ??
|
customIcon?.light ??
|
||||||
absoluteHref('~gitbook/icon?size=small&theme=light', true),
|
(await getAbsoluteHref('~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 ??
|
||||||
absoluteHref('~gitbook/icon?size=small&theme=dark', true),
|
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
media: '(prefers-color-scheme: dark)',
|
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).
|
* For preview, the theme can be set via query string (?theme=light).
|
||||||
*/
|
*/
|
||||||
function getQueryStringTheme() {
|
async function getQueryStringTheme() {
|
||||||
const headersList = headers();
|
const headersList = await 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 { absoluteHref } from '@/lib/links';
|
import { getAbsoluteHref } 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 = getSiteContentPointer();
|
const pointer = await 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/',
|
||||||
...(isSpaceIndexable({ space, site })
|
...((await isSpaceIndexable({ space, site }))
|
||||||
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
|
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
|
||||||
: [`Disallow: /`]),
|
: [`Disallow: /`]),
|
||||||
];
|
];
|
||||||
const content = lines.join('\n');
|
const content = lines.join('\n');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import jsontoxml from 'jsontoxml';
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
import { getSpaceContentData } from '@/lib/api';
|
import { getSpaceContentData } from '@/lib/api';
|
||||||
import { absoluteHref } from '@/lib/links';
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
import { getPagePath } from '@/lib/pages';
|
import { getPagePath } from '@/lib/pages';
|
||||||
import { getSiteContentPointer } from '@/lib/pointer';
|
import { getSiteContentPointer } from '@/lib/pointer';
|
||||||
import { isPageIndexable } from '@/lib/seo';
|
import { isPageIndexable } from '@/lib/seo';
|
||||||
@@ -14,31 +14,33 @@ export const runtime = 'edge';
|
|||||||
* Generate a sitemap.xml for the current space.
|
* Generate a sitemap.xml for the current space.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const pointer = getSiteContentPointer();
|
const pointer = await getSiteContentPointer();
|
||||||
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
|
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
|
||||||
|
|
||||||
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||||
const urls = pages.map(({ page, depth }) => {
|
const urls = await Promise.all(
|
||||||
// Decay priority with depth
|
pages.map(async ({ page, depth }) => {
|
||||||
const priority = Math.pow(2, -0.25 * depth);
|
// Decay priority with depth
|
||||||
// Normalize to keep 2 decimals
|
const priority = Math.pow(2, -0.25 * depth);
|
||||||
const normalizedPriority = Math.floor(100 * priority) / 100;
|
// 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 {
|
return {
|
||||||
url: {
|
url: {
|
||||||
loc: absoluteHref(getPagePath(rootPages, page), true),
|
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
|
||||||
priority: normalizedPriority,
|
priority: normalizedPriority,
|
||||||
...(lastModified
|
...(lastModified
|
||||||
? {
|
? {
|
||||||
// lastmod format is YYYY-MM-DD
|
// lastmod format is YYYY-MM-DD
|
||||||
lastmod: new Date(lastModified).toISOString().split('T')[0],
|
lastmod: new Date(lastModified).toISOString().split('T')[0],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const xml = jsontoxml(
|
const xml = jsontoxml(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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 = getSiteContentPointer();
|
const pointer = await getSiteContentPointer();
|
||||||
const spaceId = pointer.spaceId;
|
const spaceId = pointer.spaceId;
|
||||||
|
|
||||||
const [space, { customization }] = await Promise.all([
|
const [space, { customization }] = await Promise.all([
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { NextRequest } from 'next/server';
|
|||||||
import colorContrast from 'postcss-color-contrast/js';
|
import colorContrast from 'postcss-color-contrast/js';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { absoluteHref } from '@/lib/links';
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ export const runtime = 'edge';
|
|||||||
/**
|
/**
|
||||||
* Render the OpenGraph image for a space.
|
* Render the OpenGraph image for a space.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
|
||||||
const { space, page, customization, site } = await fetchPageData(params);
|
const { space, page, customization, site } = await fetchPageData(await params);
|
||||||
|
|
||||||
if (customization.socialPreview.url) {
|
if (customization.socialPreview.url) {
|
||||||
// If user configured a custom social preview, we redirect to it.
|
// 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
|
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
|
||||||
};
|
};
|
||||||
|
|
||||||
const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
|
const [gridWhite, gridBlack] = await Promise.all([
|
||||||
const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
|
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
|
||||||
|
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
|
||||||
|
]);
|
||||||
|
|
||||||
let gridAsset = useLightTheme ? gridBlack : gridWhite;
|
let gridAsset = useLightTheme ? gridBlack : gridWhite;
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const favicon = function () {
|
const favicon = await (async () => {
|
||||||
if ('icon' in customization.favicon)
|
if ('icon' in customization.favicon)
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
@@ -107,19 +109,12 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
|
|||||||
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
|
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
return (
|
const src = await getAbsoluteHref(
|
||||||
<img
|
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
|
||||||
src={absoluteHref(
|
true,
|
||||||
`~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={tcls('mr-4')} />;
|
||||||
|
})();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
@@ -165,7 +160,7 @@ export async function GET(req: NextRequest, { params }: { params: PageIdParams }
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div tw={tcls('flex')}>
|
<div tw={tcls('flex')}>
|
||||||
{favicon()}
|
{favicon}
|
||||||
<h3 tw={tcls('text-4xl', 'my-0')}>
|
<h3 tw={tcls('text-4xl', 'my-0')}>
|
||||||
{getContentTitle(space, customization, site ?? null)}
|
{getContentTitle(space, customization, site ?? null)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { captureException } from '@sentry/nextjs';
|
import { captureException, withScope } from '@sentry/nextjs';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
@@ -15,7 +15,12 @@ export default function ErrorPage(props: {
|
|||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
captureException(error);
|
withScope((scope) => {
|
||||||
|
if ('_componentStack' in error && error._componentStack) {
|
||||||
|
scope.setExtra('componentStack', error._componentStack);
|
||||||
|
}
|
||||||
|
captureException(error);
|
||||||
|
});
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 = getSiteContentPointer();
|
const content = await 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([
|
||||||
|
|||||||
@@ -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 = getSiteContentPointer();
|
const pointer = await getSiteContentPointer();
|
||||||
const { customization } = await getSiteData(pointer);
|
const { customization } = await getSiteData(pointer);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 = getSiteOrSpacePointerForPDF();
|
const pointer = await getSiteOrSpacePointerForPDF();
|
||||||
const { customization } = await ('siteId' in pointer
|
const { customization } = await ('siteId' in pointer
|
||||||
? getSiteData(pointer)
|
? getSiteData(pointer)
|
||||||
: getSpaceLayoutData(pointer.spaceId));
|
: getSpaceLayoutData());
|
||||||
|
|
||||||
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(spaceId: string) {
|
async function getSpaceLayoutData() {
|
||||||
const [{ customization }, scripts] = await Promise.all([
|
const [{ customization }, scripts] = await Promise.all([
|
||||||
getSpaceCustomization(spaceId),
|
getSpaceCustomization(),
|
||||||
[] as SpaceIntegrationScript[],
|
[] as SpaceIntegrationScript[],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
getSpaceContentData,
|
getSpaceContentData,
|
||||||
getSiteData,
|
getSiteData,
|
||||||
} 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';
|
||||||
@@ -39,10 +39,10 @@ const DEFAULT_LIMIT = 100;
|
|||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const pointer = getSiteOrSpacePointerForPDF();
|
const pointer = await 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(pointer.spaceId),
|
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
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.
|
* 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 } }) {
|
export default async function PDFHTMLOutput(props: {
|
||||||
const pointer = getSiteOrSpacePointerForPDF();
|
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));
|
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 = absoluteHref('~gitbook/pdf', true);
|
let currentPDFUrl = await getAbsoluteHref('~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(pointer.spaceId),
|
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
|
||||||
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||||
]);
|
]);
|
||||||
const language = getSpaceLanguage(customization);
|
const language = getSpaceLanguage(customization);
|
||||||
@@ -74,7 +76,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
// 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, pagePDFContainerId(page)] as [string, string],
|
({ page }) => [page.id, getPagePDFContainerId(page)] as [string, string],
|
||||||
);
|
);
|
||||||
const linksContext: PageHrefContext = {
|
const linksContext: PageHrefContext = {
|
||||||
pdf: pages.map(({ page }) => page.id),
|
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')}>
|
<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 ?? absoluteHref('')}
|
href={pdfParams.back ?? (await getAbsoluteHref(''))}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex',
|
'flex',
|
||||||
'flex-row',
|
'flex-row',
|
||||||
@@ -156,7 +158,7 @@ export default async function PDFHTMLOutput(props: { searchParams: { [key: strin
|
|||||||
<React.Suspense
|
<React.Suspense
|
||||||
key={page.id}
|
key={page.id}
|
||||||
fallback={
|
fallback={
|
||||||
<PrintPage id={pagePDFContainerId(page)}>
|
<PrintPage id={getPagePDFContainerId(page)}>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</PrintPage>
|
</PrintPage>
|
||||||
}
|
}
|
||||||
@@ -201,7 +203,7 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
|
|||||||
const { page } = props;
|
const { page } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrintPage id={pagePDFContainerId(page)}>
|
<PrintPage id={getPagePDFContainerId(page)}>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'break-before-page',
|
'break-before-page',
|
||||||
@@ -229,7 +231,7 @@ async function PDFPageDocument(props: {
|
|||||||
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
|
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrintPage id={pagePDFContainerId(page)}>
|
<PrintPage id={getPagePDFContainerId(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>
|
||||||
@@ -248,7 +250,7 @@ async function PDFPageDocument(props: {
|
|||||||
},
|
},
|
||||||
contentRefContext: refContext,
|
contentRefContext: refContext,
|
||||||
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
|
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
|
||||||
getId: (id) => pagePDFContainerId(page, id),
|
getId: (id) => getPagePDFContainerId(page, id),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -8,7 +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 function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
|
export async function getSiteOrSpacePointerForPDF(): Promise<
|
||||||
|
SiteContentPointer | SpaceContentPointer
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
return getSiteContentPointer();
|
return getSiteContentPointer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
||||||
|
import { withSentry } from '@sentry/cloudflare';
|
||||||
|
|
||||||
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||||
|
|
||||||
/**
|
const exportedHandler = {
|
||||||
* 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 {
|
|
||||||
async fetch(request, env, ctx) {
|
async fetch(request, env, ctx) {
|
||||||
const response = await withMiddlewareHeadersStorage(() =>
|
const response = await withMiddlewareHeadersStorage(() =>
|
||||||
nextOnPagesHandler.fetch(request, env, ctx),
|
nextOnPagesHandler.fetch(request, env, ctx),
|
||||||
@@ -15,4 +12,19 @@ export default {
|
|||||||
|
|
||||||
return response;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { AdItem } from './types';
|
|||||||
/**
|
/**
|
||||||
* Classic rendering for an ad.
|
* 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 (
|
return (
|
||||||
<a
|
<a
|
||||||
className={tcls(
|
className={tcls(
|
||||||
@@ -28,25 +32,18 @@ export function AdClassicRendering({ ad }: { ad: AdItem }) {
|
|||||||
rel="sponsored noopener"
|
rel="sponsored noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{'smallImage' in ad ? (
|
{smallImgSrc && 'smallImage' in ad ? (
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img alt="Ads logo" className={tcls('rounded-md')} src={smallImgSrc} />
|
||||||
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
|
<img alt="Ads logo" src={logoSrc} />
|
||||||
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>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { AdCover } from './types';
|
|||||||
/**
|
/**
|
||||||
* Cover rendering for an ad.
|
* Cover rendering for an ad.
|
||||||
*/
|
*/
|
||||||
export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||||
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ 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) : getPlaceholderAd();
|
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
|
||||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,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 } = getUserAgentAndIp();
|
const { ip, userAgent } = await 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}`);
|
||||||
@@ -86,8 +86,8 @@ async function fetchAd({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlaceholderAd(): { ad: AdItem; ip: string } {
|
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
|
||||||
const { ip } = getUserAgentAndIp();
|
const { ip } = await getUserAgentAndIp();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ad: {
|
ad: {
|
||||||
@@ -119,8 +119,8 @@ function getPlaceholderAd(): { ad: AdItem; ip: string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserAgentAndIp() {
|
async function getUserAgentAndIp() {
|
||||||
const headersSet = headers();
|
const headersSet = await 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') ??
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import * as React from 'react';
|
|||||||
import { Button } from '@/components/primitives';
|
import { Button } from '@/components/primitives';
|
||||||
import { useLanguage } from '@/intl/client';
|
import { useLanguage } from '@/intl/client';
|
||||||
import { t, tString } from '@/intl/translate';
|
import { t, tString } from '@/intl/translate';
|
||||||
import { isCookiesTrackingDisabled, setCookiesTracking } from '@/lib/analytics';
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { isCookiesTrackingDisabled, setCookiesTracking } from '../Insights';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toast to accept or reject the use of cookies.
|
* Toast to accept or reject the use of cookies.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DocumentBlockContentRef } from '@gitbook/api';
|
import { DocumentBlockContentRef } from '@gitbook/api';
|
||||||
|
|
||||||
import { Card, Emoji } from '@/components/primitives';
|
import { Card } 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';
|
||||||
|
|
||||||
@@ -34,6 +34,10 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
|
|||||||
href={resolved.href}
|
href={resolved.href}
|
||||||
title={resolved.text}
|
title={resolved.text}
|
||||||
style={style}
|
style={style}
|
||||||
|
insights={{
|
||||||
|
target: block.data.ref,
|
||||||
|
position: 'content',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,7 @@ async function SpaceRefCard(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
|
const { customization: spaceCustomization } = await getSpaceCustomization();
|
||||||
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;
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
createCssVariablesTheme,
|
createCssVariablesTheme,
|
||||||
HighlighterGeneric,
|
HighlighterGeneric,
|
||||||
} from 'shiki/core';
|
} from 'shiki/core';
|
||||||
import { loadWasm, createOnigurumaEngine } from 'shiki/engine/oniguruma';
|
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
|
||||||
import { bundledLanguages } from 'shiki/langs';
|
import { BundledLanguage, bundledLanguages } from 'shiki/langs';
|
||||||
// @ts-ignore - onigWasm is a Wasm module
|
|
||||||
import onigWasm from 'shiki/onig.wasm?module';
|
|
||||||
|
|
||||||
import { asyncMutexFunction, singleton } from '@/lib/async';
|
import { asyncMutexFunction, singleton } from '@/lib/async';
|
||||||
import { getNodeText } from '@/lib/document';
|
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.
|
* Validate a language name.
|
||||||
*/
|
*/
|
||||||
function getLanguageForSyntax(syntax: string): keyof typeof bundledLanguages | null {
|
function getLanguageForSyntax(syntax: string): BundledLanguage | null {
|
||||||
// @ts-ignore
|
// Normalize the syntax to lowercase.
|
||||||
const lang = bundledLanguages[syntax];
|
syntax = syntax.toLowerCase();
|
||||||
if (!lang) {
|
|
||||||
return null;
|
// Check if the syntax is a bundled language.
|
||||||
|
if (checkIsBundledLanguage(syntax)) {
|
||||||
|
return syntax;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// Check if there is a valid alias for the syntax.
|
||||||
return 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>({
|
const createHighlighter = createdBundledHighlighter<any, any>({
|
||||||
langs: bundledLanguages,
|
langs: bundledLanguages,
|
||||||
themes: {},
|
themes: {},
|
||||||
engine: () => createOnigurumaEngine(import('shiki/wasm')),
|
engine: () =>
|
||||||
|
createJavaScriptRegexEngine({
|
||||||
|
forgiving: true,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,14 +326,6 @@ const createHighlighter = createdBundledHighlighter<any, any>({
|
|||||||
*/
|
*/
|
||||||
const loadHighlighter = singleton(async () => {
|
const loadHighlighter = singleton(async () => {
|
||||||
return await trace('highlighting.loadHighlighter', 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({
|
const highlighter = await createHighlighter({
|
||||||
themes: [createCssVariablesTheme()],
|
themes: [createCssVariablesTheme()],
|
||||||
langs: [],
|
langs: [],
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ 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 = 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 });
|
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 { BlockProps } from './Block';
|
||||||
import { Caption } from './Caption';
|
import { Caption } from './Caption';
|
||||||
import { FileIcon } from './FileIcon';
|
import { FileIcon } from './FileIcon';
|
||||||
|
import { Link } from '../primitives';
|
||||||
|
|
||||||
export async function File(props: BlockProps<DocumentBlockFile>) {
|
export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||||
const { block, context } = props;
|
const { block, context } = props;
|
||||||
@@ -21,9 +22,13 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Caption {...props} wrapperStyle={[]}>
|
<Caption {...props} wrapperStyle={[]}>
|
||||||
<a
|
<Link
|
||||||
href={file.downloadURL}
|
href={file.downloadURL}
|
||||||
download={file.name}
|
download={file.name}
|
||||||
|
insights={{
|
||||||
|
target: block.data.ref,
|
||||||
|
position: 'content',
|
||||||
|
}}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'group/file',
|
'group/file',
|
||||||
'flex',
|
'flex',
|
||||||
@@ -77,7 +82,7 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
|||||||
{contentType}
|
{contentType}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Link>
|
||||||
</Caption>
|
</Caption>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isInLink = ancestorInlines.some((ancestor) => ancestor.type === 'link');
|
const isInLink = ancestorInlines.some((ancestor) => ancestor.type === 'link');
|
||||||
|
const sizes = await getImageSizes(size, src);
|
||||||
|
|
||||||
return (
|
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 */
|
/* 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
|
<Image
|
||||||
alt={inline.data.caption ?? ''}
|
alt={inline.data.caption ?? ''}
|
||||||
sizes={await getImageSizes(size, src)}
|
sizes={sizes}
|
||||||
sources={{
|
sources={{
|
||||||
light: {
|
light: {
|
||||||
src: src.href,
|
src: src.href,
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={resolved.href}
|
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
|
<Inlines
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export function ListItem(props: BlockProps<DocumentBlockListItem>) {
|
|||||||
'[&:is(h2)>div]:mt-0',
|
'[&:is(h2)>div]:mt-0',
|
||||||
'[&:is(h3)>div]:mt-0',
|
'[&:is(h3)>div]:mt-0',
|
||||||
'[&:is(h4)>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"
|
style="space-y-2 flex flex-col flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -135,7 +137,7 @@ function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.R
|
|||||||
className={tcls(
|
className={tcls(
|
||||||
textStyle.textSize,
|
textStyle.textSize,
|
||||||
textStyle.lineHeight,
|
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}
|
{props.children}
|
||||||
|
|||||||
@@ -15,5 +15,15 @@ export async function Mention(props: InlineProps<DocumentInlineMention>) {
|
|||||||
return null;
|
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 { Icon } from '@gitbook/icons';
|
||||||
import { OpenAPIOperation } from '@gitbook/react-openapi';
|
import { OpenAPIOperation } from '@gitbook/react-openapi';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -16,7 +16,7 @@ import './scalar.css';
|
|||||||
/**
|
/**
|
||||||
* Render an OpenAPI block.
|
* Render an OpenAPI block.
|
||||||
*/
|
*/
|
||||||
export async function OpenAPI(props: BlockProps<DocumentBlockSwagger>) {
|
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
|
||||||
const { block, style } = props;
|
const { block, style } = props;
|
||||||
return (
|
return (
|
||||||
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
|
<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 { block, context } = props;
|
||||||
const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef);
|
const { data, specUrl, error } = await fetchOpenAPIBlock(block, context.resolveContentRef);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ContentRef, DocumentTableViewCards } from '@gitbook/api';
|
import { ContentRef, DocumentTableViewCards } from '@gitbook/api';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Link } from '@/components/primitives';
|
||||||
import { Image } from '@/components/utils';
|
import { Image } from '@/components/utils';
|
||||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
@@ -153,17 +154,21 @@ export async function RecordCard(
|
|||||||
'before:dark:ring-light/2',
|
'before:dark:ring-light/2',
|
||||||
] as ClassValue;
|
] as ClassValue;
|
||||||
|
|
||||||
if (target) {
|
if (target && targetRef) {
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
href={target.href}
|
href={target.href}
|
||||||
className={tcls(style, [
|
className={tcls(style, [
|
||||||
'hover:before:ring-dark/4',
|
'hover:before:ring-dark/4',
|
||||||
'dark:hover:before:ring-light/4',
|
'dark:hover:before:ring-light/4',
|
||||||
])}
|
])}
|
||||||
|
insights={{
|
||||||
|
target: targetRef,
|
||||||
|
position: 'content',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{body}
|
{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 { Icon } from '@gitbook/icons';
|
||||||
import assertNever from 'assert-never';
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
@@ -148,6 +148,17 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
href={ref.href}
|
href={ref.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style={['flex', 'flex-row', 'items-center', 'gap-2']}
|
style={['flex', 'flex-row', 'items-center', 'gap-2']}
|
||||||
|
insights={
|
||||||
|
ref.file
|
||||||
|
? {
|
||||||
|
target: {
|
||||||
|
kind: 'file',
|
||||||
|
file: ref.file.id,
|
||||||
|
},
|
||||||
|
position: 'content',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{contentType === 'image' ? (
|
{contentType === 'image' ? (
|
||||||
<Image
|
<Image
|
||||||
@@ -178,8 +189,9 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
|||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 'content-ref': {
|
case 'content-ref': {
|
||||||
const resolved = value
|
const contentRef = value ? (value as ContentRef) : null;
|
||||||
? await context.resolveContentRef(value as ContentRef, {
|
const resolved = contentRef
|
||||||
|
? await context.resolveContentRef(contentRef, {
|
||||||
resolveAnchorText: true,
|
resolveAnchorText: true,
|
||||||
iconStyle: ['mr-2', 'text-dark/6', 'dark:text-light/6'],
|
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?.icon ?? null}
|
||||||
{resolved ? (
|
{resolved ? (
|
||||||
<StyledLink href={resolved.href}>{resolved.text}</StyledLink>
|
<StyledLink
|
||||||
|
href={resolved.href}
|
||||||
|
insights={
|
||||||
|
contentRef
|
||||||
|
? {
|
||||||
|
target: contentRef,
|
||||||
|
position: 'content',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{resolved.text}
|
||||||
|
</StyledLink>
|
||||||
) : null}
|
) : null}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'users': {
|
case 'users': {
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
(value as string[]).map((userId) =>
|
(value as string[]).map(async (userId) => {
|
||||||
context.resolveContentRef({
|
const contentRef: ContentRefUser = {
|
||||||
kind: 'user',
|
kind: 'user',
|
||||||
user: userId,
|
user: userId,
|
||||||
}),
|
};
|
||||||
),
|
const resolved = await context.resolveContentRef(contentRef);
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [contentRef, resolved] as const;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
|
<Tag className={tcls('text-base')} aria-labelledby={ariaLabelledBy}>
|
||||||
{resolved.filter(filterOutNullable).map((file, index) => (
|
{resolved.filter(filterOutNullable).map(([contentRef, resolved], index) => (
|
||||||
<StyledLink key={index} href={file.href}>
|
<StyledLink
|
||||||
{file.text}
|
key={index}
|
||||||
|
href={resolved.href}
|
||||||
|
insights={{
|
||||||
|
target: contentRef,
|
||||||
|
position: 'content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resolved.text}
|
||||||
</StyledLink>
|
</StyledLink>
|
||||||
))}
|
))}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.columnHeader {
|
.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 {
|
:global(.dark) .columnHeader {
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@apply flex border-tint-700/2;
|
@apply flex border-tint-700/2 hover:bg-tint-700/2 transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .row {
|
:global(.dark) .row {
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rowGroup {
|
.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 {
|
:global(.dark) .rowGroup {
|
||||||
@@ -51,9 +51,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.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 {
|
:global(.dark) .cell {
|
||||||
@apply border-light/2;
|
@apply border-tint-300/3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,43 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
|
|
||||||
|
|
||||||
import { useHash, useIsMounted } from '@/components/hooks';
|
import { useHash, useIsMounted } from '@/components/hooks';
|
||||||
|
import * as storage from '@/lib/local-storage';
|
||||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
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:
|
// How many titles are remembered:
|
||||||
const TITLES_MAX = 5;
|
const TITLES_MAX = 5;
|
||||||
|
|
||||||
@@ -14,7 +46,6 @@ export interface TabsItem {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925
|
|
||||||
type SelectorMapper<Type> = {
|
type SelectorMapper<Type> = {
|
||||||
[Property in keyof Type]: Type[Property];
|
[Property in keyof Type]: Type[Property];
|
||||||
};
|
};
|
||||||
@@ -42,18 +73,21 @@ export function DynamicTabs(
|
|||||||
const { id, tabs, tabsBody, style } = props;
|
const { id, tabs, tabsBody, style } = props;
|
||||||
|
|
||||||
const hash = useHash();
|
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 localStorage
|
||||||
|
|
||||||
// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
|
|
||||||
// once the component has been mounted.
|
// once the component has been mounted.
|
||||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||||
// and get stuck into an inconsistent state.
|
// and get stuck into an inconsistent state.
|
||||||
const mounted = useIsMounted();
|
const mounted = useIsMounted();
|
||||||
const active = mounted ? activeState : tabs[0];
|
const active = mounted ? activeState : tabs[0];
|
||||||
|
|
||||||
const setTabsState = useSetRecoilState(tabsAtom);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When clicking to select a tab, we:
|
* When clicking to select a tab, we:
|
||||||
* - mark this specific ID as selected
|
* - 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.
|
* Get the ID for a tab button.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ function Code(props: MarkedLeafProps<DocumentMarkCode>) {
|
|||||||
'py-[1px]',
|
'py-[1px]',
|
||||||
'px-1.5',
|
'px-1.5',
|
||||||
'min-w-[1.625rem]',
|
'min-w-[1.625rem]',
|
||||||
'inline-flex',
|
|
||||||
'justify-center',
|
'justify-center',
|
||||||
'items-center',
|
'items-center',
|
||||||
'leading-normal',
|
'leading-normal',
|
||||||
@@ -82,10 +81,8 @@ function Code(props: MarkedLeafProps<DocumentMarkCode>) {
|
|||||||
'ring-dark/1',
|
'ring-dark/1',
|
||||||
'bg-dark/[0.06]',
|
'bg-dark/[0.06]',
|
||||||
'rounded',
|
'rounded',
|
||||||
'text-dark/8',
|
|
||||||
'dark:ring-light/1',
|
'dark:ring-light/1',
|
||||||
'dark:bg-light/1',
|
'dark:bg-light/1',
|
||||||
'dark:text-light/7',
|
|
||||||
// Text size is proportional to the font-size of the parent element
|
// Text size is proportional to the font-size of the parent element
|
||||||
'text-[.875em]',
|
'text-[.875em]',
|
||||||
// We ensure that the code is not making the parent bigger, especially in headings
|
// 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:text-light/8',
|
||||||
'dark:hover:text-light/9',
|
'dark:hover:text-light/9',
|
||||||
)}
|
)}
|
||||||
|
insights={{
|
||||||
|
target: link.to,
|
||||||
|
position: 'footer',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{link.title}
|
{link.title}
|
||||||
</Link>
|
</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 { ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
import { Link } from '../primitives';
|
import { Link, LinkInsightsProps } from '../primitives';
|
||||||
|
|
||||||
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
|
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
|
||||||
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
|
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
|
||||||
@@ -110,19 +110,22 @@ export function DropdownMenu(props: { children: React.ReactNode }) {
|
|||||||
/**
|
/**
|
||||||
* Menu item in a dropdown.
|
* Menu item in a dropdown.
|
||||||
*/
|
*/
|
||||||
export function DropdownMenuItem(props: {
|
export function DropdownMenuItem(
|
||||||
href: string | null;
|
props: {
|
||||||
active?: boolean;
|
href: string | null;
|
||||||
className?: ClassValue;
|
active?: boolean;
|
||||||
children: React.ReactNode;
|
className?: ClassValue;
|
||||||
}) {
|
children: React.ReactNode;
|
||||||
const { children, active = false, href, className } = props;
|
} & LinkInsightsProps,
|
||||||
|
) {
|
||||||
|
const { children, active = false, href, className, insights } = props;
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
|
insights={insights}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'px-3 py-1 text-sm rounded straight-corners:rounded-sm',
|
'px-3 py-1 text-sm rounded straight-corners:rounded-sm',
|
||||||
active ? 'bg-primary/3 dark:bg-light/2 text-primary-600' : null,
|
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} />
|
<HeaderLogo site={site} space={space} customization={customization} />
|
||||||
{!hasSiteSections && isMultiVariants ? (
|
{!hasSiteSections && isMultiVariants ? (
|
||||||
<div className="z-20 shrink hidden sm:block">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CustomizationHeaderPreset,
|
CustomizationHeaderPreset,
|
||||||
SiteCustomizationSettings,
|
SiteCustomizationSettings,
|
||||||
CustomizationHeaderItem,
|
CustomizationHeaderItem,
|
||||||
|
ContentRef,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
import assertNever from 'assert-never';
|
import assertNever from 'assert-never';
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export async function HeaderLink(props: {
|
|||||||
<Dropdown
|
<Dropdown
|
||||||
className="shrink"
|
className="shrink"
|
||||||
button={(buttonProps) => {
|
button={(buttonProps) => {
|
||||||
if (!target) {
|
if (!target || !link.to) {
|
||||||
return (
|
return (
|
||||||
<HeaderItemDropdown
|
<HeaderItemDropdown
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
@@ -47,6 +48,7 @@ export async function HeaderLink(props: {
|
|||||||
return (
|
return (
|
||||||
<HeaderLinkNavItem
|
<HeaderLinkNavItem
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
|
linkTarget={link.to}
|
||||||
linkStyle={linkStyle}
|
linkStyle={linkStyle}
|
||||||
headerPreset={headerPreset}
|
headerPreset={headerPreset}
|
||||||
title={link.title}
|
title={link.title}
|
||||||
@@ -65,12 +67,13 @@ export async function HeaderLink(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (!target || !link.to) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderLinkNavItem
|
<HeaderLinkNavItem
|
||||||
|
linkTarget={link.to}
|
||||||
linkStyle={linkStyle}
|
linkStyle={linkStyle}
|
||||||
headerPreset={headerPreset}
|
headerPreset={headerPreset}
|
||||||
title={link.title}
|
title={link.title}
|
||||||
@@ -81,6 +84,7 @@ export async function HeaderLink(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type HeaderLinkNavItemProps = {
|
export type HeaderLinkNavItemProps = {
|
||||||
|
linkTarget: ContentRef;
|
||||||
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
|
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
|
||||||
headerPreset: CustomizationHeaderPreset;
|
headerPreset: CustomizationHeaderPreset;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -89,14 +93,15 @@ export type HeaderLinkNavItemProps = {
|
|||||||
} & DropdownButtonProps<HTMLElement>;
|
} & DropdownButtonProps<HTMLElement>;
|
||||||
|
|
||||||
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
|
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
|
||||||
switch (props.linkStyle) {
|
const { linkStyle, ...rest } = props;
|
||||||
|
switch (linkStyle) {
|
||||||
case 'button-secondary':
|
case 'button-secondary':
|
||||||
case 'button-primary':
|
case 'button-primary':
|
||||||
return <HeaderItemButton {...props} linkStyle={props.linkStyle} />;
|
return <HeaderItemButton {...rest} linkStyle={linkStyle} />;
|
||||||
case 'link':
|
case 'link':
|
||||||
return <HeaderItemLink {...props} />;
|
return <HeaderItemLink {...rest} />;
|
||||||
default:
|
default:
|
||||||
assertNever(props.linkStyle);
|
assertNever(linkStyle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +110,7 @@ function HeaderItemButton(
|
|||||||
linkStyle: 'button-secondary' | 'button-primary';
|
linkStyle: 'button-secondary' | 'button-primary';
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
|
const { linkTarget, linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
|
||||||
const variant = (() => {
|
const variant = (() => {
|
||||||
switch (linkStyle) {
|
switch (linkStyle) {
|
||||||
case 'button-secondary':
|
case 'button-secondary':
|
||||||
@@ -139,6 +144,10 @@ function HeaderItemButton(
|
|||||||
),
|
),
|
||||||
}[linkStyle],
|
}[linkStyle],
|
||||||
)}
|
)}
|
||||||
|
insights={{
|
||||||
|
target: linkTarget,
|
||||||
|
position: 'header',
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -158,10 +167,18 @@ function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderItemLink(props: HeaderLinkNavItemProps) {
|
function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
|
||||||
const { headerPreset, title, isDropdown, href, ...rest } = props;
|
const { linkTarget, headerPreset, title, isDropdown, href, ...rest } = props;
|
||||||
return (
|
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>
|
<span className="truncate min-w-0">{title}</span>
|
||||||
{isDropdown ? <DropdownChevron /> : null}
|
{isDropdown ? <DropdownChevron /> : null}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -198,5 +215,9 @@ async function SubHeaderLink(props: {
|
|||||||
return null;
|
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 && (
|
{'links' in link && link.links.length > 0 && (
|
||||||
<hr className="first:hidden border-t border-light-3 dark:border-dark-3 my-1 -mx-2" />
|
<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
|
{'links' in link
|
||||||
? link.links.map((subLink, index) => (
|
? link.links.map((subLink, index) => (
|
||||||
<MoreMenuLink key={index} {...props} link={subLink} />
|
<MoreMenuLink key={index} {...props} link={subLink} />
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Collection,
|
|
||||||
CustomizationHeaderPreset,
|
CustomizationHeaderPreset,
|
||||||
CustomizationSettings,
|
CustomizationSettings,
|
||||||
Site,
|
Site,
|
||||||
@@ -7,9 +6,8 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
|
|
||||||
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
|
|
||||||
import { Image } from '@/components/utils';
|
import { Image } from '@/components/utils';
|
||||||
import { absoluteHref } from '@/lib/links';
|
import { getAbsoluteHref } from '@/lib/links';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { getContentTitle } from '@/lib/utils';
|
import { getContentTitle } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -26,12 +24,13 @@ interface HeaderLogoProps {
|
|||||||
* Render the logo for a space using the customization settings.
|
* 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 { customization } = props;
|
||||||
|
const href = await getAbsoluteHref('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={absoluteHref('')}
|
href={href}
|
||||||
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
|
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
|
||||||
>
|
>
|
||||||
{customization.header.logo ? (
|
{customization.header.logo ? (
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useLanguage, tString } from '@/intl/client';
|
import { useLanguage, tString } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
|
import { useScrollListener } from '../hooks/useScrollListener';
|
||||||
|
|
||||||
const globalClassName = 'navigation-open';
|
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) {
|
if (window.scrollY >= scrollDistance) {
|
||||||
setHasScrolled(true);
|
setHasScrolled(true);
|
||||||
} else {
|
} else {
|
||||||
setHasScrolled(false);
|
setHasScrolled(false);
|
||||||
}
|
}
|
||||||
};
|
}, windowRef);
|
||||||
|
|
||||||
// Close the navigation when navigating to a page
|
// Close the navigation when navigating to a page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.remove(globalClassName);
|
document.body.classList.remove(globalClassName);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './Header';
|
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
Reference in New Issue
Block a user