mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 15:15:55 +00:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ec64dad58 | |||
| 326d674254 | |||
| a6a78ce5ac | |||
| 56cb2f4c9a | |||
| f92e90603f | |||
| 14172791e9 | |||
| e4e2f524d4 | |||
| fc7b16f6a7 | |||
| 6ba3ae74d5 | |||
| c71d1598d8 | |||
| 5950657b0e | |||
| 0e1a48cd06 | |||
| 98245e5f20 | |||
| 0b6ddca981 | |||
| ea1468c892 | |||
| e8e64bf510 | |||
| aaf8daf0d7 | |||
| 8af1abc4e1 | |||
| 48ab59feb3 | |||
| f64d5073d4 | |||
| 53b9f10ac8 | |||
| 75606e4df7 | |||
| 0643b7f351 | |||
| 5b3b4e0614 | |||
| 8d039d1348 | |||
| 87eea73081 | |||
| 67a6fb4c87 | |||
| 23584c9862 | |||
| 2f767125c4 | |||
| c73e07d42d | |||
| 6b5d7e6057 | |||
| 076dc48e89 | |||
| bb208ab36a | |||
| 60e35001b6 | |||
| 29a0d7ea02 | |||
| 1f2b7f7acf | |||
| 39e5cb63ef | |||
| 5b5928fb8d | |||
| 08068743a5 | |||
| 35eae1adcf | |||
| e85d357abd | |||
| 31396d8109 | |||
| 664debc0bc | |||
| 56c31c76b3 | |||
| 2d1a71f3d1 | |||
| c67cd73058 | |||
| 2572cb4352 | |||
| c9f6c7ccac | |||
| b6520b0dbc | |||
| 9fe8142117 | |||
| 57cdd25b5f | |||
| 1005ee5d52 | |||
| d9bb9f9a07 | |||
| 75a6fabe61 | |||
| 75e29b69cc | |||
| ffd3937538 | |||
| 575587e4c5 | |||
| 2ce59d7c92 | |||
| 3b3d6e2a73 | |||
| f2a467fdbf | |||
| 867481c66d | |||
| fc8065b7a8 | |||
| 28f1ed9205 | |||
| 568266bc9a | |||
| 7739f341b8 | |||
| 2b501f191c | |||
| aa2ed0fda1 | |||
| 51cba074d7 | |||
| 2c945c663c | |||
| d9a18ecabb | |||
| eab7931afb | |||
| 99b6c92377 | |||
| 4d56f1163b | |||
| 07cf835551 | |||
| 061c0c1a90 | |||
| 1ed18c05ef | |||
| ca134c8a60 | |||
| 7ba67fd852 | |||
| 7675c2c411 | |||
| 7c71363af8 | |||
| d48926eb2a | |||
| d843e5e06f | |||
| a2e564738e | |||
| 54a797a0b1 | |||
| a78c1ecb4d | |||
| 4771c78001 | |||
| b7a5106f36 | |||
| 4bcbdc50ff | |||
| 5d72b35c7f | |||
| ff50ac215c | |||
| 3fe3809416 | |||
| 363d337a72 | |||
| f89b31c940 | |||
| 630c3e84dd | |||
| d43202f137 | |||
| 0964043489 | |||
| e9b31a545d | |||
| 7f28a88374 | |||
| 07ea45bf84 | |||
| a86d35f721 | |||
| d6c28a0ff6 | |||
| a7af3ca7bc | |||
| cda08a9843 | |||
| 7588774237 | |||
| 683373b263 | |||
| 673ec8fb5d | |||
| 63bacaa671 | |||
| e3a3d6a505 | |||
| 175171dda1 | |||
| a4b63b88c8 | |||
| c3675fdfe7 | |||
| 2fa08519b6 | |||
| 065627060b | |||
| 2982b6a808 | |||
| c3f56ecd40 | |||
| fe9e6c1afd | |||
| 73394ae929 | |||
| c754fc9ff4 | |||
| 1ca6208057 | |||
| cdfae4d4df | |||
| c3da5469ca | |||
| 79f2d8ba76 | |||
| 7b1bdb92a6 | |||
| f9a075eed1 | |||
| 32e4193557 | |||
| 133c3e7c0a | |||
| 6ce3cea682 | |||
| 250e77d18b | |||
| e3bfe006e9 | |||
| 76aeecdc68 | |||
| 15d2ee3e76 | |||
| b066db479f | |||
| a69ff2789a | |||
| 4df235d64a | |||
| 11524457e3 | |||
| 8f7364293e | |||
| 56f5fa1fbe | |||
| b075f0f7e9 | |||
| 0ca32afe8d | |||
| 32c2f257a9 | |||
| a7066ccbe1 | |||
| 09248e0efe | |||
| e2ef22fc5f | |||
| 75f825e4f8 | |||
| 9b8d5198e4 | |||
| e09f747727 | |||
| 16e6171750 | |||
| f885e887ea | |||
| 5096f7fbc5 | |||
| f16560c1ee | |||
| d0f4860759 | |||
| 34d36c6de1 | |||
| 042b850b5d |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"public"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1.1.40
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Avoid multiple <h1> in the page by using a <div> for the title in the header
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Adapt code to pull token from customer backend generated custom cookies
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Add Python support in Code Samples
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
RND-3532: drop down menu for hidden links at small screen size
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Change dark mode shadow for multi-space search toolbar
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use ellipsis and fix icon color for more links in the header on small screen
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/icons': patch
|
||||
---
|
||||
|
||||
Fix first run of the package in development mode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/cache-do': minor
|
||||
---
|
||||
|
||||
First version of a new cache backend powered by Cloudflare Durable Objects
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for Norwegian language
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve contrast of search box placeholder
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for page icons
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Only display spaces dropdown in compact header when site is multi-variants
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Track events for site insights using the new dedicated API.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/icons': minor
|
||||
---
|
||||
|
||||
Initial release for `@gitbook/icons`, a package of content icons to be used for pages, documents, etc.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve colour contrast of list item decoration
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Persist state of tabs and dynamically sync them based on title
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-contentkit': minor
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Send redirectOnError param to getPublishedContent when token is pulled from cookie
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Ensure "Sponsored via GitBook" can be translated in all languages
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Track clicks on links (header, footer, content) for site insights.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use url hash to open Expandable and scroll to anchor
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix margin for paragraphs in quote blocks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix emojis display
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Add required query parameters to the code sample
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
---
|
||||
|
||||
Display pattern if available in parmas in OpenAPI block
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix inconsistent click area in table because of scroll indicator
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Synchronize response and response example tabs
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Show definition title when visible in cards
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Rollback of scalar modal while fixing perf issue
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Prevent codemirror from loading multiple versions in scalar
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Optimize images rendered in community ads
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-contentkit': patch
|
||||
---
|
||||
|
||||
Fix rendering of webframe with SSR causing wrong communication between frame and renderer
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix variant selector contrast for non-default themes
|
||||
@@ -2,4 +2,4 @@
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
bump: scalar from 1.0.5 to 1.0.7
|
||||
Updated scalar depdenency
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix crash when `example` is undefined for a response
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Add support for icons style customization for sites
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Adds tabs to Response Example section e.g. for status code examples
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Render mandatory headers in code sample
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Update Scalar client to latest version
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Prevent search indexation for pages where it's configured as disabled
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix bullet list display on full size blocks
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix table of contents being visible on mobile when disabled at the page level
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Track an event into site insights when visitor is opening the Scalar API client.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Update rendering of community ads to match new API response, and make it possible to preview ads.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-math': minor
|
||||
---
|
||||
|
||||
Export binary `gitbook-math` to copy assets to a directory
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix "ip" being passed to BSA for community ads
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/icons': minor
|
||||
---
|
||||
|
||||
Fix bin gitbook-icons when installed without the Pro token
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Don't show hidden pages in the empty state of a page
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix linking to a tab itself
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
|
||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
||||
- name: 🧰 Cache Playwright browser binaries
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: '~/.cache/ms-playwright'
|
||||
|
||||
@@ -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'
|
||||
+62
-56
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Cloudflare Pages
|
||||
@@ -13,29 +13,21 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
statuses: write
|
||||
outputs:
|
||||
deployment_url: ${{ steps.cloudflare.outputs.url }}
|
||||
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Cache Next.js build
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/.next/cache
|
||||
# Generate a new cache whenever packages or source files change.
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
|
||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/bun.lockb') }}-
|
||||
- name: Sets env vars for production
|
||||
run: |
|
||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||
@@ -52,27 +44,55 @@ jobs:
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
- id: cloudflare
|
||||
name: Publish to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3.11.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: ./packages/gitbook/.vercel/output/static
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '3.82.0'
|
||||
command: pages deploy ./packages/gitbook/.vercel/output/static --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ github.ref == 'refs/heads/main' && 'main' || format('pr{0}', github.event.pull_request.number) }}
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "ID: ${{ steps.cloudflare.outputs.id }}"
|
||||
echo "URL: ${{ steps.cloudflare.outputs.url }}"
|
||||
echo "Environment: ${{ steps.cloudflare.outputs.environment }}"
|
||||
echo "Alias: ${{ steps.cloudflare.outputs.alias }}"
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
|
||||
- name: Archive build output
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: .vercel/
|
||||
# Until https://github.com/cloudflare/wrangler-action/issues/301 is done
|
||||
- name: Update Deployment Status to Success
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-d '{"state": "success", "target_url": "${{ steps.deploy.outputs.deployment-url }}", "description": "Deployed Preview URL for commit", "context": "cloudflare/preview"}' \
|
||||
https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }}
|
||||
|
||||
- name: Find GitHub Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: GitBook Preview
|
||||
|
||||
- name: Create or update GitHub comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
**GitBook Preview**
|
||||
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
|
||||
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
|
||||
edit-mode: replace
|
||||
visual-testing:
|
||||
runs-on: ubuntu-latest
|
||||
name: Visual Testing
|
||||
@@ -80,10 +100,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
@@ -106,10 +124,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
@@ -125,10 +141,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
@@ -140,10 +154,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
@@ -155,10 +167,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
@@ -169,16 +179,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build (Open Source)
|
||||
env:
|
||||
NPMRC_FONT_AWESOME_TOKEN: ''
|
||||
NPM_TOKEN_READONLY: ''
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: bun install
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- run: bun run build
|
||||
@@ -188,10 +196,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
|
||||
@@ -17,10 +17,8 @@ jobs:
|
||||
with:
|
||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||
fetch-depth: 0
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
@@ -34,7 +32,7 @@ jobs:
|
||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
release-preview:
|
||||
@@ -45,10 +43,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjUiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCA2NSA2NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI3LjM5NjQgMzMuNjc2NEMzMC41MjU1IDM1LjQ4MjQgMzIuMDkgMzYuMzg1NCAzMy44MDgzIDM2LjM4NjlDMzUuNTI2NSAzNi4zODg0IDM3LjA5MjYgMzUuNDg4MiA0MC4yMjQ5IDMzLjY4NzdMNjAuMTkxNCAyMi4yMTA0QzYxLjA5MjcgMjEuNjkyMiA2MS42NDg0IDIwLjczMTggNjEuNjQ4NCAxOS42OTIxQzYxLjY0ODQgMTguNjUyNCA2MS4wOTI3IDE3LjY5MiA2MC4xOTE0IDE3LjE3MzlMNDAuMjE3NyA1LjY5MjQ1QzM3LjA4ODggMy44OTM4NiAzNS41MjQzIDIuOTk0NTcgMzMuODA3OCAyLjk5NTI0QzMyLjA5MTIgMi45OTU5MSAzMC41Mjc1IDMuODk2NDIgMjcuNCA1LjY5NzQ0TDEwLjIyOTMgMTUuNTg1NUMxMC4xMDIgMTUuNjU4OCAxMC4wMzg0IDE1LjY5NTQgOS45NzkwOCAxNS43MzAxQzQuMTEzNzEgMTkuMTYzNyAwLjQ4OTg5MiAyNS40MzIzIDAuNDQxNDM4IDMyLjIyODZDMC40NDA5NDggMzIuMjk3MyAwLjQ0MDk0OCAzMi4zNzA4IDAuNDQwOTQ4IDMyLjUxNzZDMC40NDA5NDggMzIuNjY0MyAwLjQ0MDk0OCAzMi43Mzc2IDAuNDQxNDM3IDMyLjgwNjNDMC40ODk3ODUgMzkuNTk0OSA0LjEwNTUyIDQ1Ljg1NzcgOS45NjA0NCA0OS4yOTRDMTAuMDE5NiA0OS4zMjg3IDEwLjA4MzIgNDkuMzY1NCAxMC4yMTAyIDQ5LjQzODdMMjAuOTY1OSA1NS42NDg3QzI3LjIzMzIgNTkuMjY3MyAzMC4zNjY4IDYxLjA3NjYgMzMuODA4MSA2MS4wNzc3QzM3LjI0OTMgNjEuMDc4OSA0MC4zODQyIDU5LjI3MTcgNDYuNjUzOSA1NS42NTc0TDU4LjAwOCA0OS4xMTIxQzYxLjE0NzQgNDcuMzAyMyA2Mi43MTcxIDQ2LjM5NzQgNjMuNTc5IDQ0LjkwNTZDNjQuNDQwOSA0My40MTM5IDY0LjQ0MDkgNDEuNjAyIDY0LjQ0MDkgMzcuOTc4NFYzMC45NzgxQzY0LjQ0MDkgMjkuOTcyOCA2My44OTY1IDI5LjA0NjQgNjMuMDE4MiAyOC41NTczQzYyLjE2ODQgMjguMDgzOSA2MS4xMzI1IDI4LjA5MSA2MC4yODkxIDI4LjU3NThMMzcuMDA3NCA0MS45NTg4QzM1LjQ0NTQgNDIuODU2NyAzNC42NjQzIDQzLjMwNTYgMzMuODA3MyA0My4zMDU5QzMyLjk1MDIgNDMuMzA2MiAzMi4xNjg5IDQyLjg1NzcgMzAuNjA2MyA0MS45NjA3TDE0Ljg0ODcgMzIuOTE1NUMxNC4wNTk0IDMyLjQ2MjQgMTMuNjY0NyAzMi4yMzU5IDEzLjM0NzcgMzIuMTk1QzEyLjYyNSAzMi4xMDE3IDExLjkzMDEgMzIuNTA2NiAxMS42NTQ4IDMzLjE4MTNDMTEuNTM0MSAzMy40NzcyIDExLjUzNjUgMzMuOTMyMiAxMS41NDE0IDM0Ljg0MjRDMTEuNTQ1IDM1LjUxMjQgMTEuNTQ2OCAzNS44NDc0IDExLjYwOTQgMzYuMTU1NkMxMS43NDk3IDM2Ljg0NTYgMTIuMTEyNyAzNy40NzA1IDEyLjY0MjggMzcuOTM0MUMxMi44Nzk1IDM4LjE0MTEgMTMuMTY5NiAzOC4zMDg1IDEzLjc1IDM4LjY0MzVMMzAuNTk3NCA0OC4zNjcyQzMyLjE2NDEgNDkuMjcxNCAzMi45NDc0IDQ5LjcyMzUgMzMuODA3NSA0OS43MjM3QzM0LjY2NzcgNDkuNzIzOSAzNS40NTEzIDQ5LjI3MjMgMzcuMDE4NCA0OC4zNjg5TDU3LjY2ODQgMzYuNDY1NEM1OC4yMDM3IDM2LjE1NjkgNTguNDcxNCAzNi4wMDI2IDU4LjY3MjEgMzYuMTE4NUM1OC44NzI3IDM2LjIzNDUgNTguODcyNyAzNi41NDM0IDU4Ljg3MjcgMzcuMTYxM1Y0MC4zMzY1QzU4Ljg3MjcgNDEuMjQyNCA1OC44NzI3IDQxLjY5NTQgNTguNjU3MiA0Mi4wNjgzQzU4LjQ0MTggNDIuNDQxMyA1OC4wNDkzIDQyLjY2NzUgNTcuMjY0NCA0My4xMTk5TDQwLjIzMjIgNTIuOTM4QzM3LjA5NjYgNTQuNzQ1NCAzNS41Mjg4IDU1LjY0OTIgMzMuODA3OSA1NS42NDg0QzMyLjA4NjkgNTUuNjQ3NiAzMC41MTk5IDU0Ljc0MjQgMjcuMzg2IDUyLjkzMjFMMTEuNDUwOSA0My43MjdDMTEuNDAwMyA0My42OTc4IDExLjM3NSA0My42ODMyIDExLjM1MTQgNDMuNjY5NEM4LjAxMDIzIDQxLjcxNyA1Ljk0ODU5IDM4LjE0NTEgNS45MjkyNSAzNC4yNzU0QzUuOTI5MTIgMzQuMjQ4IDUuOTI5MTIgMzQuMjE4OCA1LjkyOTEyIDM0LjE2MDRWMzEuMjQ1OEM1LjkyOTEyIDI5LjEwOTUgNy4wNjY4OSAyNy4xMzQ5IDguOTE1MTMgMjYuMDYzNkMxMC41NDgzIDI1LjExNjkgMTIuNTYyOCAyNS4xMTUxIDE0LjE5NzcgMjYuMDU4N0wyNy4zOTY0IDMzLjY3NjRaIiBmaWxsPSIjRjJGN0Y3Ii8+Cjwvc3ZnPgo=&labelColor=5c5c5c&color=3F89A1&label=%20" alt="Documented on GitBook"></a>
|
||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Open_Source-❤️-FDA599?"/></a>
|
||||
<a href="/LICENSE"><img src="https://img.shields.io/badge/License-GNU_GPLv3-F4E28D"/></a>
|
||||
<a href="/.github/CONTRIBUTING.md"><img src="https://img.shields.io/github/contributors/gitbookIO/gitbook"/></a>
|
||||
@@ -119,6 +119,26 @@ If you plan to distribute the code, you must make the source code public to comp
|
||||
|
||||
See `LICENSE` for more information.
|
||||
|
||||
## Badges
|
||||
|
||||
<p align="left">
|
||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"></a>
|
||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=F4E28D"></a>
|
||||
<a href="https://gitbook.com"><img src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=FDA599"></a>
|
||||
</p>
|
||||
|
||||
```md
|
||||
[](https://gitbook.com/)
|
||||
```
|
||||
|
||||
```html
|
||||
<a href="https://gitbook.com">
|
||||
<img
|
||||
src="https://img.shields.io/static/v1?message=Documented%20on%20GitBook&logo=gitbook&logoColor=ffffff&label=%20&labelColor=5c5c5c&color=3F89A1"
|
||||
/>
|
||||
</a>
|
||||
```
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
GitBook wouldn't be possible without these projects:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[install.scopes]
|
||||
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
|
||||
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
+43
-32
@@ -1,34 +1,45 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.1.18",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"build:cloudflare": "turbo run build:cloudflare",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint -- --fix",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"format": "prettier ./ --ignore-unknown --write",
|
||||
"format:check": "prettier ./ --ignore-unknown --list-different",
|
||||
"unit": "turbo run unit",
|
||||
"e2e": "turbo run e2e",
|
||||
"changeset": "changeset",
|
||||
"release": "turbo run release && changeset publish",
|
||||
"release:preview": "turbo run release:preview",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.1.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@vercel/next@4.3.6": "patches/@vercel%2Fnext@4.3.6.patch"
|
||||
}
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.1.2"
|
||||
},
|
||||
"packageManager": "bun@1.1.18",
|
||||
"patchedDependencies": {
|
||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch",
|
||||
"@cloudflare/next-on-pages@1.13.5": "patches/@cloudflare%2Fnext-on-pages@1.13.5.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"build:cloudflare": "turbo run build:cloudflare",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint -- --fix",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"format": "prettier ./ --ignore-unknown --write",
|
||||
"format:check": "prettier ./ --ignore-unknown --list-different",
|
||||
"unit": "turbo run unit",
|
||||
"e2e": "turbo run e2e",
|
||||
"changeset": "changeset",
|
||||
"release": "turbo run release && changeset publish",
|
||||
"release:preview": "turbo run release:preview",
|
||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# @gitbook/cache-do
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b7a5106: Disable cloudflare observability in production
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9b8d519: Experiment with optimizing billable duration in Cloudflare by using multiple RPC sessions instead of one
|
||||
- 636b868: First version of a new cache backend powered by Cloudflare Durable Objects
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 56f5fa1: Enable Workers observability with a sampling of 0.1
|
||||
@@ -14,14 +14,14 @@
|
||||
"default": "./dist/api.js"
|
||||
}
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"lru_map": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "^3.78.7"
|
||||
"wrangler": "3.82.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
|
||||
@@ -53,16 +53,20 @@ export class CacheObject extends DurableObject {
|
||||
* Get the value of a property.
|
||||
*/
|
||||
public async get<Value = unknown>(key: string) {
|
||||
return timeFn(`get: ${key}`, async () => {
|
||||
return this.logOperation({ operation: 'get', key }, async (setLog) => {
|
||||
// Try the memory state first.
|
||||
const memoryEntry = this.lru.get(key);
|
||||
if (memoryEntry) {
|
||||
console.log(`get: (memory, ${!!memoryEntry.match}) ${key}`);
|
||||
setLog({ memory: true });
|
||||
setLog({ memoryMatch: !!memoryEntry.match });
|
||||
if (!memoryEntry.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (memoryEntry.match.expiresAt > Date.now()) {
|
||||
const isExpired = memoryEntry.match.expiresAt < Date.now();
|
||||
setLog({ memoryExpired: isExpired });
|
||||
|
||||
if (!isExpired) {
|
||||
return memoryEntry.match.value as Value;
|
||||
}
|
||||
}
|
||||
@@ -75,28 +79,31 @@ export class CacheObject extends DurableObject {
|
||||
* Get the value of a property from the DO storage.
|
||||
*/
|
||||
public async getFromStorage<Value = unknown>(key: string) {
|
||||
const entries = await this.ctx.storage.list<Uint8Array>({
|
||||
prefix: getStoragePropKey(key),
|
||||
noCache: true,
|
||||
});
|
||||
if (entries.size) {
|
||||
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
// Found
|
||||
this.lru.set(key, { match: entry });
|
||||
return entry.value;
|
||||
return this.logOperation({ operation: 'getFromStorage', key }, async (setLog) => {
|
||||
const entries = await this.ctx.storage.list<Uint8Array>({
|
||||
prefix: getStoragePropKey(key),
|
||||
noCache: true,
|
||||
});
|
||||
if (entries.size) {
|
||||
const entry = decodeChunks<CacheObjectProp<Value>>(entries);
|
||||
setLog({ chunks: entries.size, chunksSize: entry?.size ?? 0 });
|
||||
if (entry && entry.value.expiresAt > Date.now()) {
|
||||
// Found
|
||||
this.lru.set(key, { match: entry.value });
|
||||
return entry.value.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
this.lru.set(key, { match: undefined });
|
||||
// Not found
|
||||
this.lru.set(key, { match: undefined });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache object.
|
||||
*/
|
||||
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
return timeFn(`set: ${key}`, async () => {
|
||||
return this.logOperation({ operation: 'set', key }, async (setLog) => {
|
||||
const prop: CacheObjectProp<Value> = {
|
||||
value,
|
||||
expiresAt,
|
||||
@@ -105,10 +112,12 @@ export class CacheObject extends DurableObject {
|
||||
this.lru.set(key, { match: prop });
|
||||
await this.ctx.storage.transaction(async (tx) => {
|
||||
const entries = encodeChunks(key, prop);
|
||||
const chunks = Object.keys(entries).length;
|
||||
setLog({ chunks });
|
||||
|
||||
const clockValue: CacheObjectExp = {
|
||||
k: key,
|
||||
c: Object.keys(entries).length,
|
||||
c: chunks,
|
||||
};
|
||||
|
||||
await tx.put(getGCClockKey(key, expiresAt), clockValue);
|
||||
@@ -127,76 +136,101 @@ export class CacheObject extends DurableObject {
|
||||
* Purge all keys in the cache object.
|
||||
*/
|
||||
public async purge() {
|
||||
let result = new Set<string>();
|
||||
return this.logOperation({ operation: 'purge' }, async (setLog) => {
|
||||
let result = new Set<string>();
|
||||
|
||||
try {
|
||||
// List all the keys in the cache object.
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
console.log(`purge: ${entries.size} entries`);
|
||||
entries.forEach((exp) => {
|
||||
result.add(exp.k);
|
||||
});
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during purge, resetting the cache object', error);
|
||||
}
|
||||
try {
|
||||
// List all the keys in the cache object.
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
setLog({ entries: entries.size });
|
||||
entries.forEach((exp) => {
|
||||
result.add(exp.k);
|
||||
});
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during purge, resetting the cache object', error);
|
||||
}
|
||||
|
||||
await this.reset();
|
||||
|
||||
console.log(`purge returns`, Array.from(result));
|
||||
return Array.from(result);
|
||||
await this.reset();
|
||||
return Array.from(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm to garbage collect all entries that have expired.
|
||||
*/
|
||||
async alarm() {
|
||||
try {
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
const toDeleteSet = new Set<string>();
|
||||
return this.logOperation({ operation: 'alarm' }, async (setLog) => {
|
||||
try {
|
||||
const entries = await this.ctx.storage.list<CacheObjectExp>({
|
||||
prefix: 'exp.',
|
||||
noCache: true,
|
||||
});
|
||||
setLog({ entries: entries.size });
|
||||
const toDeleteSet = new Set<string>();
|
||||
|
||||
for (const [key, exp] of entries) {
|
||||
const timestamp = parseInt(key.split('.')[1]);
|
||||
if (timestamp < Date.now()) {
|
||||
toDeleteSet.add(key);
|
||||
for (let i = 0; i < exp.c; i++) {
|
||||
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
|
||||
for (const [key, exp] of entries) {
|
||||
const timestamp = parseInt(key.split('.')[1]);
|
||||
if (timestamp < Date.now()) {
|
||||
toDeleteSet.add(key);
|
||||
for (let i = 0; i < exp.c; i++) {
|
||||
toDeleteSet.add(getStoragePropChunkKey(exp.k, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the keys by batch of 128.
|
||||
const toDelete = Array.from(toDeleteSet);
|
||||
for (let i = 0; i < toDelete.length; i += 128) {
|
||||
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
|
||||
}
|
||||
// Delete the keys by batch of 128.
|
||||
const toDelete = Array.from(toDeleteSet);
|
||||
setLog({ toDelete: toDelete.length });
|
||||
for (let i = 0; i < toDelete.length; i += 128) {
|
||||
await this.ctx.storage.delete(toDelete.slice(i, i + 128));
|
||||
}
|
||||
|
||||
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
|
||||
if (toDelete.length) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
// If there are still keys to delete, set an alarm to continue the deletion in 12h.
|
||||
if (toDelete.length) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during alarm, reset the cache object', error);
|
||||
await this.reset();
|
||||
}
|
||||
} catch (error) {
|
||||
// If an error occurs, reset the cache object.
|
||||
// This is a safety mechanism to prevent the cache object from being stuck in a bad state.
|
||||
console.error('Error during alarm, reset the cache object', error);
|
||||
await this.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cache object.
|
||||
*/
|
||||
async reset() {
|
||||
console.log('reset: clear all entries');
|
||||
this.lru.clear();
|
||||
await this.ctx.storage.deleteAll();
|
||||
return this.logOperation({ operation: 'reset' }, async () => {
|
||||
this.lru.clear();
|
||||
await this.ctx.storage.deleteAll();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Time and log an operation.
|
||||
*/
|
||||
async logOperation<T>(
|
||||
log: Record<string, unknown>,
|
||||
fn: (update: (log: Record<string, unknown>) => void) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const objectId = this.ctx.id.name ?? this.ctx.id.toString();
|
||||
let update: Record<string, unknown> = {};
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await fn((arg) => {
|
||||
Object.assign(update, arg);
|
||||
});
|
||||
} finally {
|
||||
const duration = performance.now() - start;
|
||||
console.log({ ...log, ...update, objectId, duration });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +262,7 @@ function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function decodeChunks<T>(entries: Map<string, Uint8Array>): T | undefined {
|
||||
function decodeChunks<T>(entries: Map<string, Uint8Array>): { value: T; size: number } | undefined {
|
||||
const chunks = Array.from(entries.entries())
|
||||
.map(([key, value]) => {
|
||||
const index = parseInt(key.split('.').pop()!);
|
||||
@@ -242,7 +276,7 @@ function decodeChunks<T>(entries: Map<string, Uint8Array>): T | undefined {
|
||||
}
|
||||
|
||||
const buf = mergeUint8Array(chunks);
|
||||
return decode(buf) as T;
|
||||
return { value: decode(buf) as T, size: buf.length };
|
||||
}
|
||||
|
||||
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
|
||||
@@ -263,12 +297,3 @@ function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function timeFn<T>(message: string, fn: () => Promise<T>): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log(`${message} (${performance.now() - start}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const doLocationHints: {
|
||||
* Client to access a cache tag.
|
||||
*/
|
||||
export class CacheObjectStub {
|
||||
private opened: CacheObjectDescriptor | null = null;
|
||||
private stub: DurableObjectStub<CacheObject>;
|
||||
|
||||
constructor(
|
||||
/** Binding to the CacheObject durable object */
|
||||
@@ -31,41 +31,42 @@ export class CacheObjectStub {
|
||||
private locationId: CacheLocationId,
|
||||
/** Name of the tag */
|
||||
private tag: string,
|
||||
) {}
|
||||
) {
|
||||
const groupId = getCacheObjectIdName(this.locationId, this.tag);
|
||||
this.stub = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||
// Initialize the object with a locaiton hint,
|
||||
// as we might want to purge all locations before the object is created.
|
||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||
locationHint: doLocationHints[this.locationId],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the cache object.
|
||||
* Open a descriptor to the cache object.
|
||||
* It can be used to perform multiple operations in a single RPC session.
|
||||
* Ex:
|
||||
* ```ts
|
||||
* using desc = cache.open();
|
||||
* await desc.set('key', 'value', Date.now() + 1000);
|
||||
* await desc.get('key');
|
||||
* ```
|
||||
*/
|
||||
async open() {
|
||||
if (!this.opened) {
|
||||
const groupId = getCacheObjectIdName(this.locationId, this.tag);
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||
// Initialize the object with a locaiton hint,
|
||||
// as we might want to purge all locations before the object is created.
|
||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||
locationHint: doLocationHints[this.locationId],
|
||||
});
|
||||
this.opened = await cacheGroup.open();
|
||||
}
|
||||
|
||||
return this.opened;
|
||||
return await this.stub.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
*/
|
||||
async get<Value = unknown>(key: string) {
|
||||
const desc = await this.open();
|
||||
return await desc.get<Value>(key);
|
||||
return (await this.stub.get(key)) as Value | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache.
|
||||
*/
|
||||
async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
// TODO: Should we write on all locations instead of just the current one?
|
||||
const desc = await this.open();
|
||||
return await desc.set<Value>(key, value, expiresAt);
|
||||
return await this.stub.set(key, value, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,12 @@ export class CacheObjectStub {
|
||||
await Promise.all(
|
||||
allLocations.map(async (locationId) => {
|
||||
const groupId = getCacheObjectIdName(locationId, this.tag);
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId));
|
||||
const cacheGroup = this.doNamespace.get(this.doNamespace.idFromName(groupId), {
|
||||
// Initialize the object with a locaiton hint,
|
||||
// as we might want to purge all locations before the object is created.
|
||||
// https://developers.cloudflare.com/durable-objects/reference/data-location/
|
||||
locationHint: doLocationHints[this.locationId],
|
||||
});
|
||||
const locationkeys = await cacheGroup.purge();
|
||||
locationkeys.forEach((key) => keys.add(key));
|
||||
}),
|
||||
|
||||
@@ -10,8 +10,18 @@ migrations = [
|
||||
{tag = "v1", new_classes = ["CacheObject"]}
|
||||
]
|
||||
|
||||
[observability]
|
||||
enabled = false
|
||||
|
||||
[env.preview]
|
||||
name = "gitbook-open-cache-preview"
|
||||
durable_objects.bindings = [
|
||||
{name = "CACHE", class_name = "CacheObject"}
|
||||
]
|
||||
migrations = [
|
||||
{tag = "v1", new_classes = ["CacheObject"]}
|
||||
]
|
||||
|
||||
[env.preview.observability]
|
||||
enabled = true
|
||||
head_sampling_rate = 0.1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,3 +25,6 @@
|
||||
|
||||
### Sentry ###
|
||||
# SENTRY_DSN=xxx
|
||||
|
||||
### Silent logs
|
||||
# SILENT=true
|
||||
@@ -32,4 +32,5 @@ screenshots/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated public files
|
||||
/public/~gitbook/static/
|
||||
/public/~gitbook/static/*
|
||||
!/public/~gitbook/static/images
|
||||
|
||||
@@ -1,5 +1,140 @@
|
||||
# gitbook
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 57cdd25: GitBook Open now supports Ask AI in sites. When asking a question to Ask AI, GitBook will use context from across your site sections and variants to provide the best answer.
|
||||
- ca134c8: Fix an issue where the active site section indicator appeared above any dropdowns.
|
||||
- d48926e: Fix an issue where the space dropdown was shown under the site sections in Safari.
|
||||
- 9fe8142: Fix an issue where Ask AI was erroring due to an object being passed as a param.
|
||||
- d843e5e: Fix an issue where the space dropdown could appear behind the header.
|
||||
- a2e5647: Fix the styling of site section tabs on smaller screens.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 076dc48: Fix expandable block anchore resolution
|
||||
- d9bb9f9: Fix an issue with the cookie banner buttons being non responsive
|
||||
- 23584c9: Update the site header with new styling, a new search button, and refactored layout
|
||||
- 664debc: Add support for tint color
|
||||
- 4d56f11: Update styling of search+ask modal
|
||||
- 061c0c1: Fix a regression in variant drop-down caused by missing z-index.
|
||||
- 2f76712: Add breadcrumbs above page title
|
||||
- 07cf835: Add scroll margin to the top when there are sections
|
||||
- 5d72b35: Smoother tab transition for sections
|
||||
- 7c71363: Don't adjust fallback font for mono font.
|
||||
- 7675c2c: Optimize performances by using new API endpoint for fetching site data.
|
||||
- 87eea73: Fix margin and image resolution of header logo
|
||||
- aa2ed0f: Restyle hint blocks
|
||||
- ffd3937: Fix security issue with image resizing that could be used for phishing
|
||||
- 2ce59d7: Fix - whitespace added to site section tabs with icons.
|
||||
- c73e07d: Increase token max length to fix code not highlighted
|
||||
- 3b3d6e2: Add icons to sections
|
||||
- 1ed18c0: style: adds missing scalar css variables
|
||||
- Updated dependencies [b7a5106]
|
||||
- Updated dependencies [4771c78]
|
||||
- Updated dependencies [ff50ac2]
|
||||
- Updated dependencies [867481c]
|
||||
- Updated dependencies [7ba67fd]
|
||||
- Updated dependencies [a78c1ec]
|
||||
- @gitbook/cache-do@0.1.1
|
||||
- @gitbook/react-openapi@0.7.1
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e09f747: Revalidate change request cached content when pressing refresh button
|
||||
- 2fa0851: Add navigation tabs for sections
|
||||
- a4b63b8: Support resolution of new site URLs with sections
|
||||
- 5c35f36: Replace all icons, previously imported from Geist, by new package `@gitbook/icons`
|
||||
- e9b31a5: Unify section tab styles with page item styles
|
||||
- f12a215: Add support for Norwegian language
|
||||
- f4c9536: Optimize layout shift while transitioning between pages with full width blocks (ex: OpenAPI blocks)
|
||||
- 1f24fe4: Add support for page icons
|
||||
- cda08a9: Add support for searching results in a sections site
|
||||
- b32e40c: Persist state of tabs and dynamically sync them based on title
|
||||
- 15d2ee3: Show the caption for file blocks
|
||||
- f885e88: Improve the toolbar for change-requests and revisions to show more actions
|
||||
- 07ea45b: Remove deprecated synced block from GitBook Open
|
||||
- c3675fd: Added support for new Reusable Content block.
|
||||
- 1f24fe4: Add support for icons style customization for sites
|
||||
- 4c19014: Prevent search indexation for pages where it's configured as disabled
|
||||
- 3422ad4: Update rendering of community ads to match new API response, and make it possible to preview ads.
|
||||
- 1152445: Changed the alternative URL resolution criteria in order to support site URLs without /v/ prefix
|
||||
- 2c437f7: Fix linking to a tab itself
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- aa32198: Avoid multiple <h1> in the page by using a <div> for the title in the header
|
||||
- 51fa3ab: Adds content-visibility css property to OpenAPI Operation for better render performance
|
||||
- a7066cc: Fix scroll position when navigating pages on mobile
|
||||
- c754fc9: Add automatic color contrast in site header, restyle search button
|
||||
- 5fe7adb: RND-3532: drop down menu for hidden links at small screen size
|
||||
- 6295881: Change dark mode shadow for multi-space search toolbar
|
||||
- f89b31c: Upgrade the scalar api client package
|
||||
- 13c7534: Use ellipsis and fix icon color for more links in the header on small screen
|
||||
- f885e88: Improve consistency of change request preview by removing cache-control on response
|
||||
- 16e6171: Improve performances of loading pages with embeds by caching them
|
||||
- 34d36c6: Fix GitBook specific static assets not being served correctly when deployed on Cloudflare
|
||||
- af9e66e: Only display spaces dropdown in compact header when site is multi-variants
|
||||
- e3a3d6a: Improve perception of fast loading by not rendering skeletons for individual blocks in the top part of the viewport
|
||||
- 042b850: Automatically scroll to active item in TOC
|
||||
- d43202f: Optimize bundle size of the server output by reducing bundle size of shiki (skipping themes)
|
||||
- bfbed1a: Ensure "Sponsored via GitBook" can be translated in all languages
|
||||
- fe9e6c1: Update ogimage with new design
|
||||
- 17f71ba: Use url hash to open Expandable and scroll to anchor
|
||||
- 3c07e65: Fix margin for paragraphs in quote blocks
|
||||
- 636b868: Use new cache backend, powered by Durable Objects, alongside the existing ones (KV, etc).
|
||||
- f16560c: Include offset in calculations of whether scrollable element is in view
|
||||
- 689f553: Fix inconsistent click area in table because of scroll indicator
|
||||
- 6ce3cea: Stop using KV cache backend for now, but also improves it for higher performances
|
||||
- e914903: Synchronize response and response example tabs
|
||||
- 0f990c7: Show definition title when visible in cards
|
||||
- e3a3d6a: Fix flickering when displaying an "Ask" answer with code blocks
|
||||
- 4cbcc5b: Rollback of scalar modal while fixing perf issue
|
||||
- 3996110: Optimize images rendered in community ads
|
||||
- 133c3e7: Update design of Checkbox to be more consistent and readable
|
||||
- 5096f7f: Disable KV cache for docs.gitbook.com as a test, also disable it for change-request to improve consistency
|
||||
- 0f1565c: Add optional env `GITBOOK_INTEGRATIONS_HOST` to configure the host serving the integrations
|
||||
- 2ff7ed1: Fix table of contents being visible on mobile when disabled at the page level
|
||||
- b075f0f: Fix accessibility of the table of contents by using `aria-current` instead of `aria-selected`
|
||||
- cb782a7: Fix "ip" being passed to BSA for community ads
|
||||
- a7af3ca: Improving the look and feel of new section tabs
|
||||
- 0bf985a: Don't show hidden pages in the empty state of a page
|
||||
- d6c28a0: Update header styling of sections, variant selector, and button links
|
||||
|
||||
- Change position of variant selector depending on context (next to logo or in table of contents)
|
||||
- Update section tab styling and animation
|
||||
- Make header buttons smaller with a new `medium` button size
|
||||
|
||||
- Updated dependencies [51fa3ab]
|
||||
- Updated dependencies [9b8d519]
|
||||
- Updated dependencies [cf3045a]
|
||||
- Updated dependencies [f89b31c]
|
||||
- Updated dependencies [d0f4860]
|
||||
- Updated dependencies [ef9d012]
|
||||
- Updated dependencies [094e9cd]
|
||||
- Updated dependencies [636b868]
|
||||
- Updated dependencies [56f5fa1]
|
||||
- Updated dependencies [5c35f36]
|
||||
- Updated dependencies [4247361]
|
||||
- Updated dependencies [aa8c49e]
|
||||
- Updated dependencies [e914903]
|
||||
- Updated dependencies [4cbcc5b]
|
||||
- Updated dependencies [0f1565c]
|
||||
- Updated dependencies [237b703]
|
||||
- Updated dependencies [51955da]
|
||||
- Updated dependencies [a679e72]
|
||||
- Updated dependencies [c079c3c]
|
||||
- Updated dependencies [5c35f36]
|
||||
- Updated dependencies [776bc31]
|
||||
- @gitbook/react-openapi@0.7.0
|
||||
- @gitbook/cache-do@0.1.0
|
||||
- @gitbook/icons@0.1.0
|
||||
- @gitbook/react-contentkit@0.5.1
|
||||
- @gitbook/react-math@0.6.0
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exclude": ["/~gitbook/static/*"]
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
CustomizationBackground,
|
||||
CustomizationCorners,
|
||||
CustomizationFont,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationLocale,
|
||||
CustomizationSidebarBackgroundStyle,
|
||||
CustomizationSidebarListStyle,
|
||||
CustomizationThemeMode,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { test, expect, Page, BrowserContext } from '@playwright/test';
|
||||
import deepMerge from 'deepmerge';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import rison from 'rison';
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import {
|
||||
getVisitorAuthCookieName,
|
||||
getVisitorAuthCookieValue,
|
||||
VISITOR_TOKEN_COOKIE,
|
||||
} from '@/lib/visitor-token';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
|
||||
interface Test {
|
||||
name: string;
|
||||
url: string; // URL to visit for testing
|
||||
cookies?: Parameters<BrowserContext['addCookies']>[0];
|
||||
run?: (page: Page) => Promise<unknown>; // The test to run
|
||||
fullPage?: boolean; // Whether the test should be fullscreened during testing
|
||||
screenshot?: false; // Should a screenshot be stored
|
||||
only?: boolean; // Only run this test
|
||||
}
|
||||
|
||||
interface TestsCase {
|
||||
@@ -33,6 +48,18 @@ const allLocales: CustomizationLocale[] = [
|
||||
CustomizationLocale.Zh,
|
||||
];
|
||||
|
||||
const allThemeModes: CustomizationThemeMode[] = [
|
||||
CustomizationThemeMode.Light,
|
||||
CustomizationThemeMode.Dark,
|
||||
];
|
||||
|
||||
const allThemePresets: CustomizationHeaderPreset[] = [
|
||||
CustomizationHeaderPreset.Default,
|
||||
CustomizationHeaderPreset.Bold,
|
||||
CustomizationHeaderPreset.Contrast,
|
||||
CustomizationHeaderPreset.Custom,
|
||||
];
|
||||
|
||||
async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
||||
@@ -94,7 +121,9 @@ const testCases: TestsCase[] = [
|
||||
name: 'Variants dropdown',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
|
||||
const spaceDrowpdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.waitFor();
|
||||
},
|
||||
},
|
||||
@@ -104,13 +133,15 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
{
|
||||
name: 'RFC variant',
|
||||
url: 'v/rfcs',
|
||||
url: 'rfcs',
|
||||
},
|
||||
{
|
||||
name: 'Customized variant titles are displayed',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
|
||||
const spaceDrowpdown = page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
|
||||
const variantSelectionDropdown = page.locator(
|
||||
@@ -142,9 +173,9 @@ const testCases: TestsCase[] = [
|
||||
url: 'api-multi-versions/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page.waitForSelector(
|
||||
'[data-testid="space-dropdown-button"]',
|
||||
);
|
||||
const spaceDrowpdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
@@ -156,18 +187,18 @@ const testCases: TestsCase[] = [
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/v/2.0/reference/api-reference/pets?fallback=true',
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/2.0/reference/api-reference/pets?fallback=true',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Keep navigation path/route when switching variant (Share link)',
|
||||
url: 'api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/reference/api-reference/pets',
|
||||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page.waitForSelector(
|
||||
'[data-testid="space-dropdown-button"]',
|
||||
);
|
||||
const spaceDrowpdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
@@ -179,7 +210,7 @@ const testCases: TestsCase[] = [
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/bRfQbzwsK8rbN1GRxx7K/v/2.0/reference/api-reference/pets?fallback=true',
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets?fallback=true',
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -200,9 +231,9 @@ const testCases: TestsCase[] = [
|
||||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||||
})(),
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = await page.waitForSelector(
|
||||
'[data-testid="space-dropdown-button"]',
|
||||
);
|
||||
const spaceDrowpdown = await page
|
||||
.locator('[data-testid="space-dropdown-button"]')
|
||||
.locator('visible=true');
|
||||
await spaceDrowpdown.click();
|
||||
|
||||
// Click the second variant in the dropdown
|
||||
@@ -214,7 +245,7 @@ const testCases: TestsCase[] = [
|
||||
|
||||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||||
await page.waitForURL(
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/v/2.0/reference/api-reference/pets?fallback=true',
|
||||
'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/2.0/reference/api-reference/pets?fallback=true',
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -371,6 +402,11 @@ const testCases: TestsCase[] = [
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
},
|
||||
{
|
||||
name: 'Files',
|
||||
url: 'blocks/files',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Embeds',
|
||||
url: 'blocks/embeds',
|
||||
@@ -389,6 +425,10 @@ const testCases: TestsCase[] = [
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Stepper',
|
||||
url: 'blocks/stepper',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -425,29 +465,149 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Customization',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
tests: allThemeModes.flatMap((theme) => [
|
||||
{
|
||||
name: 'Without header',
|
||||
name: `Without header - Theme ${theme}`,
|
||||
url: getCustomizationURL({
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.None,
|
||||
links: [],
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With duotone icons',
|
||||
name: `With duotone icons - Theme ${theme}`,
|
||||
url:
|
||||
'page-options/page-with-icon' +
|
||||
getCustomizationURL({
|
||||
styling: {
|
||||
icons: CustomizationIconsStyle.Duotone,
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
{
|
||||
name: `With header buttons - Theme ${theme}`,
|
||||
url: getCustomizationURL({
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.Default,
|
||||
links: [
|
||||
{
|
||||
title: 'Secondary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-secondary',
|
||||
},
|
||||
{
|
||||
title: 'Primary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: `Without tint - Default preset - Theme ${theme}`,
|
||||
url: getCustomizationURL({
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.Default,
|
||||
links: [
|
||||
{
|
||||
title: 'Secondary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-secondary',
|
||||
},
|
||||
{
|
||||
title: 'Primary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
...allThemePresets.flatMap((preset) => ({
|
||||
name: `With tint - Preset ${preset} - Theme ${theme}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||||
},
|
||||
header: {
|
||||
preset,
|
||||
...(preset === CustomizationHeaderPreset.Custom
|
||||
? {
|
||||
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
|
||||
linkColor: { light: '#4DDE98', dark: '#0C693D' },
|
||||
}
|
||||
: {}),
|
||||
links: [
|
||||
{
|
||||
title: 'Secondary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-secondary',
|
||||
},
|
||||
{
|
||||
title: 'Primary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
})),
|
||||
{
|
||||
name: `With tint - Legacy background match - Theme ${theme}`,
|
||||
url: getCustomizationURL({
|
||||
styling: {
|
||||
background: CustomizationBackground.Match,
|
||||
},
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.Default,
|
||||
links: [
|
||||
{
|
||||
title: 'Secondary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-secondary',
|
||||
},
|
||||
{
|
||||
title: 'Primary button',
|
||||
to: { kind: 'url', url: 'https://www.gitbook.com' },
|
||||
style: 'button-primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'Ads',
|
||||
@@ -460,13 +620,62 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Shared space navigation (first site)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-uno/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Navigation to shared space',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const sharedSpaceLink = page.locator('a.underline');
|
||||
await sharedSpaceLink.click();
|
||||
expect(page.locator('h1')).toHaveText('shared');
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Shared space navigation (second site)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-dos/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Navigation to shared space',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const sharedSpaceLink = page.locator('a.underline');
|
||||
await sharedSpaceLink.click();
|
||||
expect(page.locator('h1')).toHaveText('shared');
|
||||
const url = page.url();
|
||||
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
|
||||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Redirects',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to SSO page',
|
||||
url: 'a/redirect/to/sso',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('h1')).toHaveText('SSO');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Share links',
|
||||
baseUrl: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Valid link',
|
||||
url: 'TGs8PkF4GWVtbmPnWhYL/',
|
||||
url: 'thDznyWXCeEoT55WB7HC/',
|
||||
},
|
||||
{
|
||||
name: 'Invalid link',
|
||||
@@ -553,7 +762,7 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spacea?jwt_token=${token}`;
|
||||
return `spacea?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
@@ -570,7 +779,7 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spaceb?jwt_token=${token}`;
|
||||
return `spaceb?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
@@ -587,7 +796,7 @@ const testCases: TestsCase[] = [
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spacec?jwt_token=${token}`;
|
||||
return `spacec?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
@@ -667,6 +876,60 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
|
||||
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/`,
|
||||
tests: [
|
||||
{
|
||||
name: 'Redirect to fallback on invalid token pulled from cookie',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
cookies: (() => {
|
||||
const basePath = '/va-site-redirects-fallback/';
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
'invalidKey',
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return [
|
||||
{
|
||||
name: getVisitorAuthCookieName(basePath),
|
||||
value: getVisitorAuthCookieValue(basePath, invalidToken),
|
||||
httpOnly: true,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
run: async (page) => {
|
||||
await expect(page).toHaveURL(/https:\/\/www.google.com/);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Show error message when invalid token is passed to url',
|
||||
screenshot: false,
|
||||
url: (() => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
'invalidKey',
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
run: async (page) => {
|
||||
await expect(page.locator('pre')).toContainText(
|
||||
'Error while validating the JWT token. Reason: The token signature is invalid.',
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Languages',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
@@ -734,14 +997,339 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Adaptive Content - VA',
|
||||
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-va/`,
|
||||
tests: [
|
||||
{
|
||||
name: 'isAlphaUser',
|
||||
url: (() => {
|
||||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
isAlphaUser: true,
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
run: async (page) => {
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha users' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta users' });
|
||||
await expect(alphaUserPage).toBeVisible();
|
||||
await expect(betaUserPage).toHaveCount(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'isBetaUser',
|
||||
url: (() => {
|
||||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
isBetaUser: true,
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
run: async (page) => {
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha users' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta users' });
|
||||
await expect(betaUserPage).toBeVisible();
|
||||
await expect(alphaUserPage).toHaveCount(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'isAlphaUser & isBetaUser',
|
||||
url: (() => {
|
||||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
isAlphaUser: true,
|
||||
isBetaUser: true,
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
run: async (page) => {
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha users' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta users' });
|
||||
await expect(alphaUserPage).toBeVisible();
|
||||
await expect(betaUserPage).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Adaptive Content - Public',
|
||||
baseUrl: `https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/`,
|
||||
tests: [
|
||||
{
|
||||
name: 'No custom cookie',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const welcomePage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Welcome Page' });
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha User' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta User' });
|
||||
|
||||
await expect(welcomePage).toBeVisible();
|
||||
await expect(alphaUserPage).toHaveCount(0);
|
||||
await expect(betaUserPage).toHaveCount(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Custom cookie with isAlphaUser claim',
|
||||
cookies: (() => {
|
||||
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
isAlphaUser: true,
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return [
|
||||
{
|
||||
name: VISITOR_TOKEN_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const welcomePage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Welcome Page' });
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha User' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta User' });
|
||||
|
||||
await expect(welcomePage).toBeVisible();
|
||||
await expect(alphaUserPage).toBeVisible();
|
||||
await expect(betaUserPage).toHaveCount(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Custom cookie with isBetaUser claim',
|
||||
cookies: (() => {
|
||||
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
isBetaUser: true,
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return [
|
||||
{
|
||||
name: VISITOR_TOKEN_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const welcomePage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Welcome Page' });
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha User' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta User' });
|
||||
|
||||
await expect(welcomePage).toBeVisible();
|
||||
await expect(betaUserPage).toBeVisible();
|
||||
await expect(alphaUserPage).toHaveCount(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Custom cookie with isAlphaUser & isBetaUser claims',
|
||||
cookies: (() => {
|
||||
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
isAlphaUser: true,
|
||||
isBetaUser: true,
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return [
|
||||
{
|
||||
name: VISITOR_TOKEN_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const welcomePage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Welcome Page' });
|
||||
const alphaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Alpha User' });
|
||||
const betaUserPage = page
|
||||
.locator('a[class*="group\\/toclink"]')
|
||||
.filter({ hasText: 'Beta User' });
|
||||
|
||||
await expect(welcomePage).toBeVisible();
|
||||
await expect(betaUserPage).toBeVisible();
|
||||
await expect(alphaUserPage).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Tables',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Default table',
|
||||
url: 'blocks/tables',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Table with straight corners',
|
||||
url:
|
||||
'blocks/tables' +
|
||||
getCustomizationURL({
|
||||
styling: {
|
||||
corners: CustomizationCorners.Straight,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Table with primary color',
|
||||
url:
|
||||
'blocks/tables' +
|
||||
getCustomizationURL({
|
||||
styling: {
|
||||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
// Test dark mode for each variant
|
||||
...allThemeModes.flatMap((theme) => [
|
||||
{
|
||||
name: `Table in ${theme} mode`,
|
||||
url:
|
||||
'blocks/tables' +
|
||||
getCustomizationURL({
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: `Table with straight corners in ${theme} mode`,
|
||||
url:
|
||||
'blocks/tables' +
|
||||
getCustomizationURL({
|
||||
styling: {
|
||||
corners: CustomizationCorners.Straight,
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: `Table with primary color in ${theme} mode`,
|
||||
url:
|
||||
'blocks/tables' +
|
||||
getCustomizationURL({
|
||||
styling: {
|
||||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||||
},
|
||||
themes: {
|
||||
default: theme,
|
||||
toggeable: false,
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
test.describe(testCase.name, () => {
|
||||
for (const testEntry of testCase.tests) {
|
||||
test(testEntry.name, async ({ page, baseURL }) => {
|
||||
const testFn = testEntry.only ? test.only : test;
|
||||
testFn(testEntry.name, async ({ page, baseURL, context }) => {
|
||||
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
||||
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
||||
if (testEntry.cookies) {
|
||||
await context.addCookies(
|
||||
testEntry.cookies.map((cookie) => ({
|
||||
...cookie,
|
||||
domain: new URL(url).host,
|
||||
path: '/',
|
||||
})),
|
||||
);
|
||||
}
|
||||
await page.goto(url);
|
||||
if (testEntry.run) {
|
||||
await testEntry.run(page);
|
||||
@@ -767,7 +1355,66 @@ for (const testCase of testCases) {
|
||||
* Create a URL with customization settings.
|
||||
*/
|
||||
function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): string {
|
||||
const encoded = rison.encode_object(partial);
|
||||
/**
|
||||
* Default customization settings.
|
||||
*
|
||||
* The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults.
|
||||
*/
|
||||
const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = {
|
||||
styling: {
|
||||
primaryColor: { light: '#346DDB', dark: '#346DDB' },
|
||||
corners: CustomizationCorners.Rounded,
|
||||
font: CustomizationFont.Inter,
|
||||
background: CustomizationBackground.Plain,
|
||||
icons: CustomizationIconsStyle.Regular,
|
||||
sidebar: {
|
||||
background: CustomizationSidebarBackgroundStyle.Default,
|
||||
list: CustomizationSidebarListStyle.Default,
|
||||
},
|
||||
},
|
||||
internationalization: {
|
||||
locale: CustomizationLocale.En,
|
||||
},
|
||||
favicon: {},
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.Default,
|
||||
links: [],
|
||||
},
|
||||
footer: {
|
||||
groups: [],
|
||||
},
|
||||
themes: {
|
||||
default: CustomizationThemeMode.Light,
|
||||
toggeable: true,
|
||||
},
|
||||
pdf: {
|
||||
enabled: true,
|
||||
},
|
||||
feedback: {
|
||||
enabled: false,
|
||||
},
|
||||
aiSearch: {
|
||||
enabled: true,
|
||||
},
|
||||
advancedCustomization: {
|
||||
enabled: true,
|
||||
},
|
||||
git: {
|
||||
showEditLink: false,
|
||||
},
|
||||
pagination: {
|
||||
enabled: true,
|
||||
},
|
||||
trademark: {
|
||||
enabled: true,
|
||||
},
|
||||
privacyPolicy: {
|
||||
url: 'https://www.gitbook.com/privacy',
|
||||
},
|
||||
socialPreview: {},
|
||||
};
|
||||
|
||||
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('customization', encoded);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
"build": "next build",
|
||||
"build:cloudflare": "next-on-pages",
|
||||
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -16,13 +16,13 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "0.60.0",
|
||||
"@gitbook/api": "^0.85.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
@@ -41,15 +41,16 @@
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "^14.2.5",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.17.4",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.0",
|
||||
"parse-cache-control": "^1.0.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"postcss-color-contrast": "^1.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"recoil": "^0.7.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -59,17 +60,18 @@
|
||||
"remark-rehype": "^11.1.0",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^1.11.1",
|
||||
"shiki": "^1.23.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.4",
|
||||
"url-join": "^5.0.0"
|
||||
"url-join": "^5.0.0",
|
||||
"usehooks-ts": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^2.0.0",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@cloudflare/next-on-pages": "^1.12.1",
|
||||
"@cloudflare/next-on-pages": "^1.13.5",
|
||||
"@cloudflare/workers-types": "^4.20240725.0",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
@@ -77,10 +79,12 @@
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/psi": "^4.1.6",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/rison": "^0.0.9",
|
||||
"autoprefixer": "^10",
|
||||
"deepmerge": "^4.3.1",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.2.5",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
@@ -88,8 +92,7 @@
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.3",
|
||||
"env-cmd": "^10.1.0",
|
||||
"ts-essentials": "^10.0.1"
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# GitBook immutable static assets
|
||||
/~gitbook/static/*
|
||||
cache-control: public,max-age=31536000,immutable
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -4,6 +4,7 @@ import {
|
||||
verifyImageSignature,
|
||||
resizeImage,
|
||||
CloudflareImageOptions,
|
||||
imagesResizingSignVersion,
|
||||
checkIsSizableImageURL,
|
||||
} from '@/lib/images';
|
||||
import { parseImageAPIURL } from '@/lib/urls';
|
||||
@@ -18,28 +19,29 @@ export const runtime = 'edge';
|
||||
export async function GET(request: NextRequest) {
|
||||
let urlParam = request.nextUrl.searchParams.get('url');
|
||||
const signature = request.nextUrl.searchParams.get('sign');
|
||||
// The current signature algorithm sets version as 1, but we need to support the older version as well
|
||||
// for previously generated content. In this case, we default to version 0.
|
||||
const signatureVersion = (request.nextUrl.searchParams.get('sv') as '1') || '0';
|
||||
|
||||
// The current signature algorithm sets version as 2, but we need to support the older version as well
|
||||
const signatureVersion = request.nextUrl.searchParams.get('sv') as string | undefined;
|
||||
if (!urlParam || !signature) {
|
||||
return new Response('Missing url/sign parameters', { status: 400 });
|
||||
}
|
||||
|
||||
const url = parseImageAPIURL(urlParam);
|
||||
|
||||
// Prevent infinite loops
|
||||
if (url.includes('/~gitbook/image')) {
|
||||
return new Response('Invalid url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
// Check again if the image can be sized, even though we checked when rendering the Image component
|
||||
// Otherwise, it's possible to pass just any link to this endpoint and trigger HTML injection on the domain
|
||||
// Also prevent infinite redirects.
|
||||
if (!checkIsSizableImageURL(url)) {
|
||||
return new Response('Invalid url parameter', { status: 400 });
|
||||
}
|
||||
|
||||
// For older signatures, we redirect to the url.
|
||||
if (signatureVersion !== imagesResizingSignVersion) {
|
||||
return Response.redirect(url, 302);
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
|
||||
const verified = await verifyImageSignature(url, { signature });
|
||||
if (!verified) {
|
||||
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
|
||||
}
|
||||
|
||||
+4
-4
@@ -3,15 +3,15 @@
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { useScrollToHash } from '@/components/hooks';
|
||||
import { useScrollPage } from '@/components/hooks';
|
||||
|
||||
/**
|
||||
* Client component to initialize interactivity for a page.
|
||||
*/
|
||||
export function PageClientLayout(props: {}) {
|
||||
export function PageClientLayout(props: { withSections?: boolean }) {
|
||||
// We use this hook in the page layout to ensure the elements for the blocks
|
||||
// are rendered before we scroll to the hash.
|
||||
useScrollToHash();
|
||||
// are rendered before we scroll to a hash or to the top of the page
|
||||
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
return null;
|
||||
+11
-7
@@ -1,14 +1,15 @@
|
||||
import { TrackPageViewEvent } from '@/components/Insights';
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { getCurrentSiteLayoutData, getSpaceLayoutData } from '@/lib/api';
|
||||
import { getSiteData, getSpaceContentData } from '@/lib/api';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
export default async function NotFound() {
|
||||
const pointer = getContentPointer();
|
||||
const { customization } = await ('siteId' in pointer
|
||||
? getCurrentSiteLayoutData(pointer)
|
||||
: getSpaceLayoutData(pointer.spaceId));
|
||||
const pointer = getSiteContentPointer();
|
||||
const [{ space }, { customization }] = await Promise.all([
|
||||
getSpaceContentData(pointer, pointer.siteShareKey),
|
||||
getSiteData(pointer),
|
||||
]);
|
||||
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
@@ -22,6 +23,9 @@ export default async function NotFound() {
|
||||
</h2>
|
||||
<p className={tcls('text-base', 'mb-4')}>{t(language, 'notfound')}</p>
|
||||
</div>
|
||||
|
||||
{/* Track the page not found as a page view */}
|
||||
<TrackPageViewEvent pageId={null} revisionId={space.revision} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-23
@@ -9,7 +9,6 @@ import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
||||
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { isSpaceIndexable, isPageIndexable } from '@/lib/seo';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageClientLayout } from './PageClientLayout';
|
||||
@@ -29,11 +28,13 @@ export default async function Page(props: {
|
||||
const {
|
||||
content: contentPointer,
|
||||
contentTarget,
|
||||
sections,
|
||||
space,
|
||||
parent,
|
||||
site,
|
||||
customization,
|
||||
pages,
|
||||
page,
|
||||
ancestors,
|
||||
document,
|
||||
} = await getPageDataWithFallback({
|
||||
pagePathParams: params,
|
||||
@@ -65,48 +66,53 @@ export default async function Page(props: {
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
siteContext: 'siteId' in contentPointer ? contentPointer : null,
|
||||
siteContext: contentPointer,
|
||||
space,
|
||||
revisionId: contentTarget.revisionId,
|
||||
pages,
|
||||
page,
|
||||
};
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 0);
|
||||
const headerOffset = { sectionsHeader: withSections, topHeader: withTopHeader };
|
||||
|
||||
return (
|
||||
<>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
|
||||
) : null}
|
||||
<div className={tcls('flex', 'flex-row')}>
|
||||
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
|
||||
<div className="flex flex-row-reverse justify-end">
|
||||
{page.layout.outline ? (
|
||||
<PageAside
|
||||
space={space}
|
||||
site={site}
|
||||
customization={customization}
|
||||
page={page}
|
||||
document={document}
|
||||
withHeaderOffset={headerOffset}
|
||||
withFullPageCover={withFullPageCover}
|
||||
withPageFeedback={withPageFeedback}
|
||||
context={contentRefContext}
|
||||
/>
|
||||
) : null}
|
||||
<PageBody
|
||||
space={space}
|
||||
contentPointer={contentPointer}
|
||||
pointer={contentPointer}
|
||||
contentTarget={contentTarget}
|
||||
customization={customization}
|
||||
context={contentRefContext}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
withPageFeedback={
|
||||
// Display the page feedback in the page footer if the aside is not visible
|
||||
withPageFeedback && !page.layout.outline
|
||||
}
|
||||
/>
|
||||
{page.layout.outline ? (
|
||||
<PageAside
|
||||
space={space}
|
||||
site={parent?.object === 'site' ? parent : undefined}
|
||||
customization={customization}
|
||||
page={page}
|
||||
document={document}
|
||||
withHeaderOffset={withTopHeader}
|
||||
withFullPageCover={withFullPageCover}
|
||||
withPageFeedback={withPageFeedback}
|
||||
context={contentRefContext}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<PageClientLayout />
|
||||
<PageClientLayout withSections={withSections} />
|
||||
</React.Suspense>
|
||||
</>
|
||||
);
|
||||
@@ -130,7 +136,7 @@ export async function generateMetadata({
|
||||
params: PagePathParams;
|
||||
searchParams: { fallback?: string };
|
||||
}): Promise<Metadata> {
|
||||
const { space, pages, page, customization, parent, ancestors } = await getPageDataWithFallback({
|
||||
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
|
||||
pagePathParams: params,
|
||||
searchParams,
|
||||
});
|
||||
@@ -140,12 +146,13 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
return {
|
||||
title: [page.title, getContentTitle(space, customization, parent)]
|
||||
title: [page.title, getContentTitle(space, customization, site ?? null)]
|
||||
.filter(Boolean)
|
||||
.join(' | '),
|
||||
description: page.description ?? '',
|
||||
alternates: {
|
||||
canonical: absoluteHref(getPagePath(pages, page), true),
|
||||
// Trim trailing slashes in canonical URL to match the redirect behavior
|
||||
canonical: absoluteHref(getPagePath(pages, page), true).replace(/\/+$/, ''),
|
||||
},
|
||||
openGraph: {
|
||||
images: [
|
||||
@@ -154,7 +161,7 @@ export async function generateMetadata({
|
||||
],
|
||||
},
|
||||
robots:
|
||||
isSpaceIndexable({ space, parent }) && isPageIndexable(ancestors, page)
|
||||
isSpaceIndexable({ space, site: site ?? null }) && isPageIndexable(ancestors, page)
|
||||
? 'index, follow'
|
||||
: 'noindex, nofollow',
|
||||
};
|
||||
+47
-42
@@ -1,6 +1,7 @@
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
@@ -18,7 +19,7 @@ import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
import { RocketLoaderDetector } from './RocketLoaderDetector';
|
||||
import { fetchSpaceData } from '../fetch';
|
||||
import { fetchContentData } from '../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -35,13 +36,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
contentTarget,
|
||||
customization,
|
||||
pages,
|
||||
parent,
|
||||
site,
|
||||
spaces,
|
||||
ancestors,
|
||||
scripts,
|
||||
} = await fetchSpaceData();
|
||||
sections,
|
||||
} = await fetchContentData();
|
||||
|
||||
ReactDOM.preconnect(api().endpoint);
|
||||
ReactDOM.preconnect(api().client.endpoint);
|
||||
if (assetsDomain) {
|
||||
ReactDOM.preconnect(assetsDomain);
|
||||
}
|
||||
@@ -54,50 +56,53 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
});
|
||||
|
||||
return (
|
||||
<ClientContexts
|
||||
nonce={nonce}
|
||||
forcedTheme={
|
||||
getQueryStringTheme() ??
|
||||
(customization.themes.toggeable ? undefined : customization.themes.default)
|
||||
}
|
||||
>
|
||||
<SpaceLayout
|
||||
space={space}
|
||||
contentTarget={contentTarget}
|
||||
parent={parent}
|
||||
spaces={spaces}
|
||||
customization={customization}
|
||||
pages={pages}
|
||||
ancestors={ancestors}
|
||||
content={content}
|
||||
<NuqsAdapter>
|
||||
<ClientContexts
|
||||
nonce={nonce}
|
||||
forcedTheme={
|
||||
getQueryStringTheme() ??
|
||||
(customization.themes.toggeable ? undefined : customization.themes.default)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SpaceLayout>
|
||||
<SpaceLayout
|
||||
space={space}
|
||||
contentTarget={contentTarget}
|
||||
site={site}
|
||||
spaces={spaces}
|
||||
sections={sections}
|
||||
customization={customization}
|
||||
pages={pages}
|
||||
ancestors={ancestors}
|
||||
content={content}
|
||||
>
|
||||
{children}
|
||||
</SpaceLayout>
|
||||
|
||||
{scripts.length > 0 ? (
|
||||
<>
|
||||
<LoadIntegrations />
|
||||
{scripts.map(({ script }) => (
|
||||
<script key={script} async src={script} nonce={nonce} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{scripts.length > 0 ? (
|
||||
<>
|
||||
<LoadIntegrations />
|
||||
{scripts.map(({ script }) => (
|
||||
<script key={script} async src={script} nonce={nonce} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
{scripts.some((script) => script.cookies) || customization.privacyPolicy.url ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<CookiesToast privacyPolicy={customization.privacyPolicy.url} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
|
||||
<RocketLoaderDetector nonce={nonce} />
|
||||
<RocketLoaderDetector nonce={nonce} />
|
||||
|
||||
<AdminToolbar space={space} content={content} />
|
||||
</ClientContexts>
|
||||
<AdminToolbar space={space} content={content} />
|
||||
</ClientContexts>
|
||||
</NuqsAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const { customization } = await fetchSpaceData();
|
||||
const { customization } = await fetchContentData();
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
@@ -108,11 +113,11 @@ export async function generateViewport(): Promise<Viewport> {
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { space, parent, customization } = await fetchSpaceData();
|
||||
const { space, site, customization } = await fetchContentData();
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
return {
|
||||
title: getContentTitle(space, customization, parent),
|
||||
title: getContentTitle(space, customization, site),
|
||||
generator: `GitBook (${buildVersion()})`,
|
||||
metadataBase: new URL(baseUrl()),
|
||||
icons: {
|
||||
@@ -133,7 +138,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
},
|
||||
],
|
||||
},
|
||||
robots: isSpaceIndexable({ space, parent }) ? 'index, follow' : 'noindex, nofollow',
|
||||
robots: isSpaceIndexable({ space, site }) ? 'index, follow' : 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getSpace, getSite } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Generate a robots.txt for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = getSiteContentPointer();
|
||||
const [site, space] = await Promise.all([
|
||||
getSite(pointer.organizationId, pointer.siteId),
|
||||
getSpace(pointer.spaceId, pointer.siteShareKey),
|
||||
]);
|
||||
|
||||
const lines = [
|
||||
`User-agent: *`,
|
||||
'Disallow: /~gitbook/',
|
||||
...(isSpaceIndexable({ space, site })
|
||||
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
|
||||
: [`Disallow: /`]),
|
||||
];
|
||||
const content = lines.join('\n');
|
||||
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
-9
@@ -5,21 +5,17 @@ import { NextRequest } from 'next/server';
|
||||
import { getSpaceContentData } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { isPageIndexable } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Generate a sitemap.xml for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = getContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(
|
||||
pointer,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const pointer = getSiteContentPointer();
|
||||
const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey);
|
||||
|
||||
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
|
||||
const urls = pages.map(({ page, depth }) => {
|
||||
@@ -88,10 +84,12 @@ function flattenPages(
|
||||
return [
|
||||
...(page.type === 'document' ? [{ page, depth }] : []),
|
||||
...page.pages.flatMap((child) =>
|
||||
child.type === 'link' ? [] : flattenPage(child, depth + 1),
|
||||
child.type === 'document' ? flattenPage(child, depth + 1) : [],
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
return rootPags.flatMap((page) => (page.type === 'link' ? [] : flattenPage(page, 0)));
|
||||
return rootPags.flatMap((page) =>
|
||||
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
|
||||
);
|
||||
}
|
||||
+8
-20
@@ -1,18 +1,11 @@
|
||||
import { ContentVisibility } from '@gitbook/api';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import React from 'react';
|
||||
|
||||
import { getContentPointer } from '@/app/(space)/fetch';
|
||||
import {
|
||||
getCollection,
|
||||
getCurrentSiteCustomization,
|
||||
getSite,
|
||||
getSpace,
|
||||
getSpaceCustomization,
|
||||
} from '@/lib/api';
|
||||
import { getSite, getSiteData, getSpace } from '@/lib/api';
|
||||
import { getEmojiForCode } from '@/lib/emojis';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
@@ -42,20 +35,15 @@ export async function GET(req: NextRequest) {
|
||||
const options = getOptions(req.url);
|
||||
const size = SIZES[options.size];
|
||||
|
||||
const pointer = getContentPointer();
|
||||
const pointer = getSiteContentPointer();
|
||||
const spaceId = pointer.spaceId;
|
||||
|
||||
const [space, customization] = await Promise.all([
|
||||
getSpace(spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
|
||||
'siteId' in pointer ? getCurrentSiteCustomization(pointer) : getSpaceCustomization(spaceId),
|
||||
const [space, { customization }] = await Promise.all([
|
||||
getSpace(spaceId, pointer.siteShareKey),
|
||||
getSiteData(pointer),
|
||||
]);
|
||||
const parent =
|
||||
'siteId' in pointer
|
||||
? await getSite(pointer.organizationId, pointer.siteId)
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
const contentTitle = getContentTitle(space, customization, parent);
|
||||
const site = await getSite(pointer.organizationId, pointer.siteId);
|
||||
const contentTitle = getContentTitle(space, customization, site);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
@@ -0,0 +1,223 @@
|
||||
import { CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import colorContrast from 'postcss-color-contrast/js';
|
||||
import React from 'react';
|
||||
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Render the OpenGraph image for a space.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
|
||||
const { space, page, customization, site } = await fetchPageData(params);
|
||||
|
||||
if (customization.socialPreview.url) {
|
||||
// If user configured a custom social preview, we redirect to it.
|
||||
redirect(customization.socialPreview.url);
|
||||
}
|
||||
|
||||
// TODO: Support all fonts available in GitBook
|
||||
// Right now this is impossible since next/font/google does not expose the cached font file
|
||||
// Another option would be to use the Satori prop `loadAdditionalAsset` [example](https://github.com/vercel/satori/blob/main/playground/pages/index.tsx),
|
||||
// but this prop isn't (yet) exposed through `ImageResponse`.
|
||||
const interRegular = await fetch(
|
||||
new URL('../../../../../../fonts/Inter/Inter-Regular.ttf', import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
const interBold = await fetch(
|
||||
new URL('../../../../../../fonts/Inter/Inter-Bold.ttf', import.meta.url),
|
||||
).then((res) => res.arrayBuffer());
|
||||
|
||||
const theme = customization.themes.default;
|
||||
const useLightTheme = theme === 'light';
|
||||
|
||||
// We have no access to CSS variables, so we'll have to hardcode some values
|
||||
const baseColors = {
|
||||
light: '#ffffff',
|
||||
dark: '#111827',
|
||||
};
|
||||
|
||||
let colors = {
|
||||
background: baseColors[theme],
|
||||
gradient: customization.styling.primaryColor[theme],
|
||||
title: customization.styling.primaryColor[theme],
|
||||
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
|
||||
};
|
||||
|
||||
const gridWhite = absoluteHref('~gitbook/static/images/ogimage-grid-white.png', true);
|
||||
const gridBlack = absoluteHref('~gitbook/static/images/ogimage-grid-black.png', true);
|
||||
|
||||
let gridAsset = useLightTheme ? gridBlack : gridWhite;
|
||||
|
||||
switch (customization.header.preset) {
|
||||
case CustomizationHeaderPreset.Custom:
|
||||
colors = {
|
||||
background: customization.header.backgroundColor?.[theme] || colors.background,
|
||||
gradient: customization.header.linkColor?.[theme] || colors.gradient,
|
||||
title: customization.header.linkColor?.[theme] || colors.title,
|
||||
body: colorContrast(
|
||||
customization.header.backgroundColor?.[theme] || colors.background,
|
||||
[baseColors.light, baseColors.dark],
|
||||
),
|
||||
};
|
||||
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
|
||||
break;
|
||||
|
||||
case CustomizationHeaderPreset.Bold:
|
||||
colors = {
|
||||
background: customization.styling.primaryColor[theme],
|
||||
gradient: colorContrast(customization.styling.primaryColor[theme], [
|
||||
baseColors.light,
|
||||
baseColors.dark,
|
||||
]),
|
||||
title: colorContrast(customization.styling.primaryColor[theme], [
|
||||
baseColors.light,
|
||||
baseColors.dark,
|
||||
]),
|
||||
body: colorContrast(customization.styling.primaryColor[theme], [
|
||||
baseColors.light,
|
||||
baseColors.dark,
|
||||
]),
|
||||
};
|
||||
gridAsset = colors.body == baseColors.light ? gridWhite : gridBlack;
|
||||
break;
|
||||
}
|
||||
|
||||
const favicon = function () {
|
||||
if ('icon' in customization.favicon)
|
||||
return (
|
||||
<img
|
||||
src={customization.favicon.icon[theme]}
|
||||
width={40}
|
||||
height={40}
|
||||
tw={tcls('mr-4')}
|
||||
alt="Icon"
|
||||
/>
|
||||
);
|
||||
if ('emoji' in customization.favicon)
|
||||
return (
|
||||
<span tw={tcls('text-4xl', 'mr-4')}>
|
||||
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<img
|
||||
src={absoluteHref(
|
||||
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
|
||||
true,
|
||||
)}
|
||||
alt="Icon"
|
||||
width={40}
|
||||
height={40}
|
||||
tw={tcls('mr-4')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw={tcls(
|
||||
'justify-between',
|
||||
'p-20',
|
||||
'relative',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'flex',
|
||||
'flex-col',
|
||||
`bg-[${colors.background}]`,
|
||||
`text-[${colors.body}]`,
|
||||
)}
|
||||
>
|
||||
{/* Gradient */}
|
||||
<div
|
||||
tw={tcls('absolute', 'inset-0')}
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(ellipse 100% 100% at top right , ${colors.gradient}, ${colors.gradient}00)`,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Grid */}
|
||||
<img
|
||||
tw={tcls('absolute', 'inset-0', 'w-[100vw]', 'h-[100vh]')}
|
||||
src={gridAsset}
|
||||
alt="Grid"
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
{customization.header.logo ? (
|
||||
<img
|
||||
alt="Logo"
|
||||
height={60}
|
||||
src={
|
||||
useLightTheme
|
||||
? customization.header.logo.light
|
||||
: customization.header.logo.dark
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div tw={tcls('flex')}>
|
||||
{favicon()}
|
||||
<h3 tw={tcls('text-4xl', 'my-0')}>
|
||||
{getContentTitle(space, customization, site ?? null)}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and description */}
|
||||
<div tw={tcls('flex', 'flex-col')}>
|
||||
<h1
|
||||
tw={tcls(
|
||||
'text-8xl',
|
||||
'my-0',
|
||||
'tracking-tight',
|
||||
'leading-none',
|
||||
'text-left',
|
||||
`text-[${colors.title}]`,
|
||||
'font-bold',
|
||||
)}
|
||||
>
|
||||
{page
|
||||
? page.title.length > 64
|
||||
? page.title.slice(0, 64) + '...'
|
||||
: page.title
|
||||
: 'Not found'}
|
||||
</h1>
|
||||
{page?.description && page?.title.length <= 64 ? (
|
||||
<h2 tw={tcls('text-4xl', 'mb-0', 'mt-8', 'w-[75%]', 'font-normal')}>
|
||||
{page.description.length > 164
|
||||
? page.description.slice(0, 164) + '...'
|
||||
: page.description}
|
||||
</h2>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interRegular,
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: interBold,
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { RevisionPage } from '@gitbook/api';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
getRevisionPageByPath,
|
||||
getDocument,
|
||||
getSpaceContentData,
|
||||
getSiteData,
|
||||
getSiteRedirectBySource,
|
||||
} from '@/lib/api';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
export interface PagePathParams {
|
||||
pathname?: string[];
|
||||
}
|
||||
|
||||
export interface PageIdParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content layout.
|
||||
*/
|
||||
export async function fetchContentData() {
|
||||
const content = getSiteContentPointer();
|
||||
|
||||
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
|
||||
await Promise.all([
|
||||
getSpaceContentData(content, content.siteShareKey),
|
||||
getSiteData(content),
|
||||
]);
|
||||
|
||||
// we grab the space attached to the parent as it contains overriden customizations
|
||||
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space: spaceRelativeToParent ?? space,
|
||||
pages,
|
||||
site,
|
||||
sections,
|
||||
spaces,
|
||||
shareKey: content.siteShareKey,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content.
|
||||
* Optimized to fetch in parallel as much as possible.
|
||||
*/
|
||||
export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const contentData = await fetchContentData();
|
||||
|
||||
const page = await resolvePage({
|
||||
organizationId: contentData.space.organization,
|
||||
siteId: contentData.site.id,
|
||||
spaceId: contentData.contentTarget.spaceId,
|
||||
revisionId: contentData.contentTarget.revisionId,
|
||||
pages: contentData.pages,
|
||||
shareKey: contentData.shareKey,
|
||||
params,
|
||||
});
|
||||
const document = page?.page.documentId
|
||||
? await getDocument(contentData.space.id, page.page.documentId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...contentData,
|
||||
...page,
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page from the params.
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(input: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
shareKey: string | undefined;
|
||||
pages: RevisionPage[];
|
||||
params: PagePathParams | PageIdParams;
|
||||
}) {
|
||||
const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input;
|
||||
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(pages, params.pageId);
|
||||
}
|
||||
|
||||
const rawPathname = getPathnameParam(params);
|
||||
const pathname = normalizePathname(rawPathname);
|
||||
|
||||
// When resolving a page, we use the lowercased pathname
|
||||
const page = resolvePagePath(pages, pathname);
|
||||
if (page) {
|
||||
return page;
|
||||
}
|
||||
|
||||
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||
if (rawPathname.length <= 512) {
|
||||
// If page can't be found, we try with the API, in case we have a redirect at space level.
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname);
|
||||
if (resolved) {
|
||||
return resolvePageId(pages, resolved.id);
|
||||
}
|
||||
|
||||
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
|
||||
const resolvedSiteRedirect = await getSiteRedirectBySource({
|
||||
organizationId,
|
||||
siteId,
|
||||
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
|
||||
siteShareKey: input.shareKey,
|
||||
});
|
||||
if (resolvedSiteRedirect) {
|
||||
return redirect(resolvedSiteRedirect.target);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page path from the params.
|
||||
*/
|
||||
export function getPathnameParam(params: PagePathParams): string {
|
||||
const { pathname } = params;
|
||||
return pathname ? pathname.map((part) => decodeURIComponent(part)).join('/') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL pathname into the format used in the revision page path.
|
||||
*/
|
||||
export function normalizePathname(pathname: string) {
|
||||
return pathname.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { getSiteData } from '@/lib/api';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
/**
|
||||
* Layout to be used for the site root. It fetches the customization data for the site
|
||||
* and initializes the CustomizationRootLayout with it.
|
||||
*/
|
||||
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
const pointer = getSiteContentPointer();
|
||||
const { customization } = await getSiteData(pointer);
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ContentVisibility } from '@gitbook/api';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getCollection, getSite, getSpace } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { isSpaceIndexable } from '@/lib/seo';
|
||||
|
||||
import { getContentPointer } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Generate a robots.txt for the current space.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = getContentPointer();
|
||||
const space = await getSpace(
|
||||
pointer.spaceId,
|
||||
'siteId' in pointer ? pointer.siteShareKey : undefined,
|
||||
);
|
||||
const parent =
|
||||
'siteId' in pointer
|
||||
? await getSite(pointer.organizationId, pointer.siteId)
|
||||
: space.visibility === ContentVisibility.InCollection && space.parent
|
||||
? await getCollection(space.parent)
|
||||
: null;
|
||||
|
||||
const lines = [
|
||||
`User-agent: *`,
|
||||
'Disallow: /~gitbook/',
|
||||
...(isSpaceIndexable({ space, parent })
|
||||
? [`Allow: /`, `Sitemap: ${absoluteHref(`/sitemap.xml`, true)}`]
|
||||
: [`Disallow: /`]),
|
||||
];
|
||||
const content = lines.join('\n');
|
||||
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import React from 'react';
|
||||
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Render the OpenGraph image for a space.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: PageIdParams }) {
|
||||
const { space, page, customization, parent } = await fetchPageData(params);
|
||||
const url = new URL(space.urls.published ?? space.urls.app);
|
||||
|
||||
if (customization.socialPreview.url) {
|
||||
// If user configured a custom social preview, we redirect to it.
|
||||
redirect(customization.socialPreview.url);
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
tw="bg-gray-50 py-16 px-14"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<h2 tw="text-7xl font-bold tracking-tight text-left">
|
||||
{getContentTitle(space, customization, parent)}
|
||||
</h2>
|
||||
<div tw="flex flex-1">
|
||||
<p tw="text-4xl">{page ? page.title : 'Not found'}</p>
|
||||
</div>
|
||||
<div tw="flex">
|
||||
<p tw="text-4xl">
|
||||
{url.hostname + (url.pathname.length > 1 ? url.pathname : '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { ContentVisibility, RevisionPage, Space } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import {
|
||||
getCollectionSpaces,
|
||||
getCollection,
|
||||
ContentPointer,
|
||||
getRevisionPageByPath,
|
||||
getDocument,
|
||||
getSpaceData,
|
||||
ContentTarget,
|
||||
SiteContentPointer,
|
||||
getCurrentSiteData,
|
||||
getSite,
|
||||
getSiteSpaces,
|
||||
getCurrentSiteCustomization,
|
||||
} from '@/lib/api';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
|
||||
export interface PagePathParams {
|
||||
pathname?: string[];
|
||||
}
|
||||
|
||||
export interface PageIdParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current content pointer from the params.
|
||||
*/
|
||||
export function getContentPointer(): ContentPointer | SiteContentPointer {
|
||||
const headerSet = headers();
|
||||
const spaceId = headerSet.get('x-gitbook-content-space');
|
||||
if (!spaceId) {
|
||||
throw new Error(
|
||||
'getContentPointer is called outside the scope of a request processed by the middleware',
|
||||
);
|
||||
}
|
||||
|
||||
const siteId = headerSet.get('x-gitbook-content-site');
|
||||
if (siteId) {
|
||||
const organizationId = headerSet.get('x-gitbook-content-organization');
|
||||
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
|
||||
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
|
||||
if (!organizationId) {
|
||||
throw new Error('Missing site content headers');
|
||||
}
|
||||
|
||||
const siteContent: SiteContentPointer = {
|
||||
siteId,
|
||||
spaceId,
|
||||
siteSpaceId: siteSpaceId ?? undefined,
|
||||
siteShareKey: siteShareKey ?? undefined,
|
||||
organizationId,
|
||||
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
|
||||
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
|
||||
};
|
||||
return siteContent;
|
||||
} else {
|
||||
const content: ContentPointer = {
|
||||
spaceId,
|
||||
revisionId: headerSet.get('x-gitbook-content-revision') ?? undefined,
|
||||
changeRequestId: headerSet.get('x-gitbook-content-changerequest') ?? undefined,
|
||||
};
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the space layout.
|
||||
*/
|
||||
export async function fetchSpaceData() {
|
||||
const content = getContentPointer();
|
||||
|
||||
const siteShareKey = 'siteId' in content ? content.siteShareKey : undefined;
|
||||
|
||||
const [{ space, contentTarget, pages, customization, scripts }, parentSite] = await Promise.all(
|
||||
'siteId' in content
|
||||
? [
|
||||
getCurrentSiteData(content),
|
||||
fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey,
|
||||
}),
|
||||
]
|
||||
: [getSpaceData(content, siteShareKey)],
|
||||
);
|
||||
|
||||
const parent = await (parentSite ?? fetchParentCollection(space));
|
||||
// we grab the space attached to the parent as it contains overriden customizations
|
||||
const spaceRelativeToParent = parent?.spaces.find((space) => space.id === content.spaceId);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space: spaceRelativeToParent ?? space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
...parent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content.
|
||||
* Optimized to fetch in parallel as much as possible.
|
||||
*/
|
||||
export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const content = getContentPointer();
|
||||
const siteShareKey = 'siteId' in content ? content.siteShareKey : undefined;
|
||||
const { space, contentTarget, pages, customization, scripts } = await ('siteId' in content
|
||||
? getCurrentSiteData(content)
|
||||
: getSpaceData(content, siteShareKey));
|
||||
|
||||
const page = await resolvePage(contentTarget, pages, params);
|
||||
const [parent, document] = await Promise.all([
|
||||
'siteId' in content
|
||||
? fetchParentSite({
|
||||
organizationId: content.organizationId,
|
||||
siteId: content.siteId,
|
||||
siteShareKey: content.siteShareKey,
|
||||
})
|
||||
: fetchParentCollection(space),
|
||||
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
content,
|
||||
contentTarget,
|
||||
space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
...page,
|
||||
...parent,
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page from the params.
|
||||
* If the path can't be found, we try to resolve it from the API to handle redirects.
|
||||
*/
|
||||
async function resolvePage(
|
||||
contentTarget: ContentTarget,
|
||||
pages: RevisionPage[],
|
||||
params: PagePathParams | PageIdParams,
|
||||
) {
|
||||
if ('pageId' in params) {
|
||||
return resolvePageId(pages, params.pageId);
|
||||
}
|
||||
|
||||
const rawPathname = getPathnameParam(params);
|
||||
const pathname = normalizePathname(rawPathname);
|
||||
|
||||
// When resolving a page, we use the lowercased pathname
|
||||
const page = resolvePagePath(pages, pathname);
|
||||
if (page) {
|
||||
return page;
|
||||
}
|
||||
|
||||
// If page can't be found, we try with the API, in case we have a redirect
|
||||
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
|
||||
// The page rendering will take care of redirecting to a normalized pathname.
|
||||
//
|
||||
// We don't test path that are too long as GitBook doesn't support them and will return a 404 anyway.
|
||||
if (rawPathname.length <= 512) {
|
||||
const resolved = await getRevisionPageByPath(
|
||||
contentTarget.spaceId,
|
||||
contentTarget.revisionId,
|
||||
rawPathname,
|
||||
);
|
||||
if (resolved) {
|
||||
return resolvePageId(pages, resolved.id);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchParentCollection(space: Space) {
|
||||
const parentCollectionId =
|
||||
space.visibility === ContentVisibility.InCollection ? space.parent : undefined;
|
||||
const [collection, spaces] = await Promise.all([
|
||||
parentCollectionId ? getCollection(parentCollectionId) : null,
|
||||
parentCollectionId ? getCollectionSpaces(parentCollectionId) : ([] as Space[]),
|
||||
]);
|
||||
|
||||
return { parent: collection, spaces };
|
||||
}
|
||||
|
||||
async function fetchParentSite(args: {
|
||||
organizationId: string;
|
||||
siteId: string;
|
||||
siteShareKey: string | undefined;
|
||||
}) {
|
||||
const { organizationId, siteId, siteShareKey } = args;
|
||||
const [site, siteSpaces, siteParentCustomizations] = await Promise.all([
|
||||
getSite(organizationId, siteId),
|
||||
getSiteSpaces({ organizationId, siteId, siteShareKey }),
|
||||
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
|
||||
]);
|
||||
|
||||
const spaces: Record<string, Space> = {};
|
||||
siteSpaces.forEach((siteSpace) => {
|
||||
spaces[siteSpace.space.id] = {
|
||||
...siteSpace.space,
|
||||
title: siteSpace.title ?? siteSpace.space.title,
|
||||
urls: {
|
||||
...siteSpace.space.urls,
|
||||
published: siteSpace.urls.published,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// override the title with the customization title
|
||||
const parent = {
|
||||
...site,
|
||||
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
parent,
|
||||
spaces: Object.values(spaces),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page path from the params.
|
||||
*/
|
||||
export function getPathnameParam(params: PagePathParams): string {
|
||||
const { pathname } = params;
|
||||
return pathname ? pathname.map((part) => decodeURIComponent(part)).join('/') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the URL pathname into the format used in the revision page path.
|
||||
*/
|
||||
export function normalizePathname(pathname: string) {
|
||||
return pathname.toLowerCase();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user