mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fac399b3f | |||
| 1d8875416b | |||
| 24a889cf50 |
@@ -1,8 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -25,6 +25,3 @@
|
||||
|
||||
### Sentry ###
|
||||
# SENTRY_DSN=xxx
|
||||
|
||||
### Silent logs
|
||||
# SILENT=true
|
||||
+7
-21
@@ -1,8 +1,6 @@
|
||||
# Welcome to GitBook's contributing guide!
|
||||
|
||||
> _For help, support, feature requests, and product questions - head to our [GitHub Community](https://github.com/orgs/GitbookIO/discussions) 🤖_
|
||||
|
||||
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team. In this guide, you'll learn the different ways you can contribute.
|
||||
Thank you for investing your time in contributing to GitBook. Any contribution you make will be reviewed by our team.In this guide, you'll learn the different ways you can contribute.
|
||||
|
||||
## Types of Contributions
|
||||
|
||||
@@ -47,25 +45,13 @@ Any contribution you make can be made to the code located in this repository. In
|
||||
|
||||
- [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them.
|
||||
|
||||
##### GitHub Codespaces:
|
||||
|
||||
- [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally.
|
||||
|
||||
#### 2. Create a working branch and start with your changes
|
||||
|
||||
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of.
|
||||
|
||||
#### 3. Install dependencies and run the project locally
|
||||
|
||||
GitBook uses [Bun](https://bun.sh/) to run the project. Make sure you're using the specified version of `node` before running any of the development commands to ensure a smooth development experience.
|
||||
|
||||
You can easily do this by running the command `nvm use`.
|
||||
|
||||
To start your local version of GitBook, run the command `bun dev`.
|
||||
|
||||
#### 4. Preview your changes
|
||||
|
||||
When running the development server, published GitBook sites can be rendered through your local version at `http://localhost:3000/`.
|
||||
|
||||
For example, our published docs can be viewed using the local version by visiting `http://localhost:3000/docs.gitbook.com` after running the development server.
|
||||
|
||||
You can visit any published GitBook site behind your development server. Please make sure your site is [published publicly](https://docs.gitbook.com/published-documentation/publish-your-content-as-a-docs-site) to ensure you can view the site correctly in your development version.
|
||||
After forking this repository, you'll want to [create a branch](https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-a-branch-for-an-issue) to work off of. After creating the branch, you can start making changes!
|
||||
|
||||
### Commit your update
|
||||
|
||||
@@ -85,4 +71,4 @@ When you're finished with the changes, [create a pull request](https://docs.gith
|
||||
|
||||
### Your PR is merged
|
||||
|
||||
Congratulations 🎉 Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
||||
Congratulations 🎉Thank you for your contribution! Once your PR is merged, your contributions will be publicly visible on the relevant repository.
|
||||
|
||||
@@ -7,12 +7,12 @@ runs:
|
||||
- name: 🏗 Prepare Playwright env
|
||||
shell: bash
|
||||
run: |
|
||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["gitbook"].dependencies["@playwright/test"].version')
|
||||
PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version')
|
||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
||||
|
||||
# Cache browser binaries, cache key is based on Playwright version and OS
|
||||
- name: 🧰 Cache Playwright browser binaries
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: '~/.cache/ms-playwright'
|
||||
@@ -24,7 +24,6 @@ runs:
|
||||
- name: 🏗 Install Playwright browser binaries & OS dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
bun x playwright install --with-deps chromium
|
||||
|
||||
@@ -32,6 +31,5 @@ runs:
|
||||
- name: 🏗 Install Playwright OS dependencies
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
working-directory: packages/gitbook
|
||||
run: |
|
||||
bun x playwright install-deps
|
||||
|
||||
+31
-78
@@ -4,8 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
NPMRC_FONT_AWESOME_TOKEN: ${{ secrets.NPMRC_FONT_AWESOME_TOKEN }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to Cloudflare Pages
|
||||
@@ -13,23 +12,29 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
statuses: write
|
||||
outputs:
|
||||
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
deployment_url: ${{ steps.cloudflare.outputs.url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- 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
|
||||
@@ -46,55 +51,27 @@ jobs:
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3.11.0
|
||||
- id: cloudflare
|
||||
name: Publish to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: ./.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 "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
echo "Alias URL: ${{ steps.deploy.outputs.deployment-alias-url }}"
|
||||
echo "ID: ${{ steps.cloudflare.outputs.id }}"
|
||||
echo "URL: ${{ steps.cloudflare.outputs.url }}"
|
||||
echo "Environment: ${{ steps.cloudflare.outputs.environment }}"
|
||||
echo "Alias: ${{ steps.cloudflare.outputs.alias }}"
|
||||
- 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
|
||||
@@ -105,7 +82,7 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Setup Playwright
|
||||
@@ -115,12 +92,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: packages/gitbook/test-results/
|
||||
retention-days: 3
|
||||
pagespeed-testing:
|
||||
runs-on: ubuntu-latest
|
||||
name: PageSpeed Testing
|
||||
@@ -131,13 +102,13 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Run pagespeed tests
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||
run: bun ./tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||
@@ -150,12 +121,12 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- run: bun format:check
|
||||
- run: bun format --check .
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Lint
|
||||
@@ -165,7 +136,7 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
@@ -180,30 +151,12 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- run: bun unit
|
||||
build-oss:
|
||||
# CI to check that the repository builds correctly on a machine without the credentials
|
||||
runs-on: ubuntu-latest
|
||||
name: Build (Open Source)
|
||||
env:
|
||||
NPMRC_FONT_AWESOME_TOKEN: ''
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- run: bun run build
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
name: Typecheck
|
||||
@@ -213,7 +166,7 @@ jobs:
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
bun-version: 1.0.33
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
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: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: npm run release
|
||||
env:
|
||||
# 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 }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
release-preview:
|
||||
# For now it releases the cache-do to both preview and production
|
||||
# Once we changed to deploy the app only on release, we should change `release:preview` in `cache-do`
|
||||
name: Release Preview
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.1.18
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Release preview packages
|
||||
run: bun run release:preview
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
+29
-5
@@ -5,6 +5,16 @@ node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
@@ -14,11 +24,25 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# Env files
|
||||
.env.local
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# visual tests
|
||||
screenshots/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated public files
|
||||
/public/~gitbook/static/
|
||||
|
||||
+1
-10
@@ -1,10 +1 @@
|
||||
.next
|
||||
.vercel
|
||||
|
||||
# Generated
|
||||
packages/emoji-codepoints/index.ts
|
||||
packages/gitbook/public/~gitbook/static/
|
||||
packages/icons/src/data/*.json
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
.next
|
||||
@@ -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=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=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjUiIGhlaWdodD0iNjUiIHZpZXdCb3g9IjAgMCA2NSA2NSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI3LjM5NjQgMzMuNjc2NEMzMC41MjU1IDM1LjQ4MjQgMzIuMDkgMzYuMzg1NCAzMy44MDgzIDM2LjM4NjlDMzUuNTI2NSAzNi4zODg0IDM3LjA5MjYgMzUuNDg4MiA0MC4yMjQ5IDMzLjY4NzdMNjAuMTkxNCAyMi4yMTA0QzYxLjA5MjcgMjEuNjkyMiA2MS42NDg0IDIwLjczMTggNjEuNjQ4NCAxOS42OTIxQzYxLjY0ODQgMTguNjUyNCA2MS4wOTI3IDE3LjY5MiA2MC4xOTE0IDE3LjE3MzlMNDAuMjE3NyA1LjY5MjQ1QzM3LjA4ODggMy44OTM4NiAzNS41MjQzIDIuOTk0NTcgMzMuODA3OCAyLjk5NTI0QzMyLjA5MTIgMi45OTU5MSAzMC41Mjc1IDMuODk2NDIgMjcuNCA1LjY5NzQ0TDEwLjIyOTMgMTUuNTg1NUMxMC4xMDIgMTUuNjU4OCAxMC4wMzg0IDE1LjY5NTQgOS45NzkwOCAxNS43MzAxQzQuMTEzNzEgMTkuMTYzNyAwLjQ4OTg5MiAyNS40MzIzIDAuNDQxNDM4IDMyLjIyODZDMC40NDA5NDggMzIuMjk3MyAwLjQ0MDk0OCAzMi4zNzA4IDAuNDQwOTQ4IDMyLjUxNzZDMC40NDA5NDggMzIuNjY0MyAwLjQ0MDk0OCAzMi43Mzc2IDAuNDQxNDM3IDMyLjgwNjNDMC40ODk3ODUgMzkuNTk0OSA0LjEwNTUyIDQ1Ljg1NzcgOS45NjA0NCA0OS4yOTRDMTAuMDE5NiA0OS4zMjg3IDEwLjA4MzIgNDkuMzY1NCAxMC4yMTAyIDQ5LjQzODdMMjAuOTY1OSA1NS42NDg3QzI3LjIzMzIgNTkuMjY3MyAzMC4zNjY4IDYxLjA3NjYgMzMuODA4MSA2MS4wNzc3QzM3LjI0OTMgNjEuMDc4OSA0MC4zODQyIDU5LjI3MTcgNDYuNjUzOSA1NS42NTc0TDU4LjAwOCA0OS4xMTIxQzYxLjE0NzQgNDcuMzAyMyA2Mi43MTcxIDQ2LjM5NzQgNjMuNTc5IDQ0LjkwNTZDNjQuNDQwOSA0My40MTM5IDY0LjQ0MDkgNDEuNjAyIDY0LjQ0MDkgMzcuOTc4NFYzMC45NzgxQzY0LjQ0MDkgMjkuOTcyOCA2My44OTY1IDI5LjA0NjQgNjMuMDE4MiAyOC41NTczQzYyLjE2ODQgMjguMDgzOSA2MS4xMzI1IDI4LjA5MSA2MC4yODkxIDI4LjU3NThMMzcuMDA3NCA0MS45NTg4QzM1LjQ0NTQgNDIuODU2NyAzNC42NjQzIDQzLjMwNTYgMzMuODA3MyA0My4zMDU5QzMyLjk1MDIgNDMuMzA2MiAzMi4xNjg5IDQyLjg1NzcgMzAuNjA2MyA0MS45NjA3TDE0Ljg0ODcgMzIuOTE1NUMxNC4wNTk0IDMyLjQ2MjQgMTMuNjY0NyAzMi4yMzU5IDEzLjM0NzcgMzIuMTk1QzEyLjYyNSAzMi4xMDE3IDExLjkzMDEgMzIuNTA2NiAxMS42NTQ4IDMzLjE4MTNDMTEuNTM0MSAzMy40NzcyIDExLjUzNjUgMzMuOTMyMiAxMS41NDE0IDM0Ljg0MjRDMTEuNTQ1IDM1LjUxMjQgMTEuNTQ2OCAzNS44NDc0IDExLjYwOTQgMzYuMTU1NkMxMS43NDk3IDM2Ljg0NTYgMTIuMTEyNyAzNy40NzA1IDEyLjY0MjggMzcuOTM0MUMxMi44Nzk1IDM4LjE0MTEgMTMuMTY5NiAzOC4zMDg1IDEzLjc1IDM4LjY0MzVMMzAuNTk3NCA0OC4zNjcyQzMyLjE2NDEgNDkuMjcxNCAzMi45NDc0IDQ5LjcyMzUgMzMuODA3NSA0OS43MjM3QzM0LjY2NzcgNDkuNzIzOSAzNS40NTEzIDQ5LjI3MjMgMzcuMDE4NCA0OC4zNjg5TDU3LjY2ODQgMzYuNDY1NEM1OC4yMDM3IDM2LjE1NjkgNTguNDcxNCAzNi4wMDI2IDU4LjY3MjEgMzYuMTE4NUM1OC44NzI3IDM2LjIzNDUgNTguODcyNyAzNi41NDM0IDU4Ljg3MjcgMzcuMTYxM1Y0MC4zMzY1QzU4Ljg3MjcgNDEuMjQyNCA1OC44NzI3IDQxLjY5NTQgNTguNjU3MiA0Mi4wNjgzQzU4LjQ0MTggNDIuNDQxMyA1OC4wNDkzIDQyLjY2NzUgNTcuMjY0NCA0My4xMTk5TDQwLjIzMjIgNTIuOTM4QzM3LjA5NjYgNTQuNzQ1NCAzNS41Mjg4IDU1LjY0OTIgMzMuODA3OSA1NS42NDg0QzMyLjA4NjkgNTUuNjQ3NiAzMC41MTk5IDU0Ljc0MjQgMjcuMzg2IDUyLjkzMjFMMTEuNDUwOSA0My43MjdDMTEuNDAwMyA0My42OTc4IDExLjM3NSA0My42ODMyIDExLjM1MTQgNDMuNjY5NEM4LjAxMDIzIDQxLjcxNyA1Ljk0ODU5IDM4LjE0NTEgNS45MjkyNSAzNC4yNzU0QzUuOTI5MTIgMzQuMjQ4IDUuOTI5MTIgMzQuMjE4OCA1LjkyOTEyIDM0LjE2MDRWMzEuMjQ1OEM1LjkyOTEyIDI5LjEwOTUgNy4wNjY4OSAyNy4xMzQ5IDguOTE1MTMgMjYuMDYzNkMxMC41NDgzIDI1LjExNjkgMTIuNTYyOCAyNS4xMTUxIDE0LjE5NzcgMjYuMDU4N0wyNy4zOTY0IDMzLjY3NjRaIiBmaWxsPSIjRjJGN0Y3Ii8+Cjwvc3ZnPgo=&labelColor=5c5c5c&color=3F89A1&label=%20" alt="Documented on GitBook"></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>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center">Welcome to GitBook, the platform for managing technical knowledge for teams.</p>
|
||||
|
||||
<p align="center">This repository contains the open source code used to render GitBook's published content.</p>
|
||||
<p align="center">This repository contains the open-source code used to render GitBook's published content.</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="GitBook Open Published Site" src="./assets/published-site.png">
|
||||
@@ -46,28 +46,19 @@ To run a local version of this project, please follow these simple steps.
|
||||
git clone https://github.com/gitbookIO/gitbook.git
|
||||
```
|
||||
|
||||
2. Ensure you are using the project's version of `node`. Running `nvm use` will change your local version to the correct one.
|
||||
|
||||
3. Install the project's dependencies through Bun.
|
||||
2. Install the project's dependencies through Bun.
|
||||
|
||||
```
|
||||
bun install
|
||||
```
|
||||
|
||||
4. Start your local development server.
|
||||
3. Start your local development server.
|
||||
|
||||
```
|
||||
bun dev
|
||||
```
|
||||
|
||||
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
|
||||
|
||||
examples:
|
||||
|
||||
- http://localhost:3000/docs.gitbook.com
|
||||
- http://localhost:3000/open-source.gitbook.io/midjourney
|
||||
|
||||
Any published GitBook site can be accessed through your local development instance, and any updates you make to the codebase will be reflected in your browser.
|
||||
Then open the space in your web browser, using http://localhost:3000/<host>/<path> (example: http://localhost:3000/docs.gitbook.com).
|
||||
|
||||
### Other development commands
|
||||
|
||||
@@ -80,7 +71,7 @@ All pull-requests will be tested against both visual and performances testing to
|
||||
|
||||
## Contributing
|
||||
|
||||
GitBook's rendering engine is fully open source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||
GitBook's rendering engine is fully open-source and built on top of [Next.js](https://nextjs.org/). Head to our [contributing guide](https://github.com/GitbookIO/gitbook/.github/CONTRIBUTING.md) to learn more about the workflow on adding your first Pull Request.
|
||||
|
||||
### Types of contributions
|
||||
|
||||
@@ -88,7 +79,7 @@ We encourage you to contribute to GitBook to help us build the best tool for doc
|
||||
|
||||
#### Translations
|
||||
|
||||
The GitBook UI is rendered using a set of translation files found in [`packages/gitbook/src/intl/translations`](/packages/gitbook/src/intl/translations/). We welcome all additional translations for the UI.
|
||||
The GitBook UI is rendered using a set of translation files found in [`src/intl/translations`](/src/intl/translations/). We welcome all additional translations for the UI.
|
||||
|
||||
#### Bugs
|
||||
|
||||
@@ -99,7 +90,7 @@ Encounter a bug or find an issue you'd like to fix? Helping us fix issues relate
|
||||
> [!WARNING]
|
||||
> While it is possible to self-host this project, we do not recommend this unless you are certain this option fits your need.
|
||||
>
|
||||
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](https://github.com/GitbookIO/gitbook/blob/main/.github/CONTRIBUTING.md) to get started._
|
||||
> _Looking to add a specific feature in GitBook? Head to our [contributing guide](/.github/CONTRIBUTING.md) to get started._
|
||||
>
|
||||
> Self-hosting this project puts the responsibility of maintaining and merging future updates on **you**. We cannot guarantee support, maintenance, or updates to forked and self-hosted instances of this project.
|
||||
>
|
||||
@@ -115,30 +106,10 @@ On the con side, you become responsible for the reliability of your published si
|
||||
|
||||
Distributed under the [GNU GPLv3 License](https://github.com/GitBookIO/gitbook/blob/main/LICENSE).
|
||||
|
||||
If you plan to distribute the code, you must make the source code public to comply with the GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
|
||||
If you plan to distribute the code, you must the source code public to comply with GNU GPLv3. To clone in a private repository, acquire a [commercial license](https://www.gitbook.com/pricing).
|
||||
|
||||
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:
|
||||
@@ -148,12 +119,6 @@ GitBook wouldn't be possible without these projects:
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Framer Motion](https://www.npmjs.com/package/framer-motion)
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/gitbookIO/gitbook/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=gitbookIO/gitbook" />
|
||||
</a>
|
||||
|
||||
## Legacy GitBook (Deprecated)
|
||||
|
||||
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[install.scopes]
|
||||
"awesome.me" = { token = "$NPMRC_FONT_AWESOME_TOKEN", url = "https://npm.fontawesome.com/" }
|
||||
@@ -0,0 +1,576 @@
|
||||
import { argosScreenshot } from '@argos-ci/playwright';
|
||||
import {
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationLocale,
|
||||
CustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import rison from 'rison';
|
||||
|
||||
import { getContentTestURL } from '../tests/utils';
|
||||
|
||||
interface Test {
|
||||
name: string;
|
||||
url: string;
|
||||
run?: (page: Page) => Promise<unknown>;
|
||||
fullPage?: boolean;
|
||||
screenshot?: false;
|
||||
}
|
||||
|
||||
interface TestsCase {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
tests: Array<Test>;
|
||||
}
|
||||
|
||||
const allLocales: CustomizationLocale[] = [
|
||||
CustomizationLocale.Fr,
|
||||
CustomizationLocale.Es,
|
||||
CustomizationLocale.Ja,
|
||||
CustomizationLocale.Zh,
|
||||
];
|
||||
|
||||
async function waitForCookiesDialog(page: Page) {
|
||||
const dialog = page.getByRole('dialog', { name: 'Cookies' });
|
||||
const accept = dialog.getByRole('button', { name: 'Accept' });
|
||||
const reject = dialog.getByRole('button', { name: 'Reject' });
|
||||
await expect(accept).toBeVisible();
|
||||
await expect(reject).toBeVisible();
|
||||
}
|
||||
|
||||
const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'GitBook Site (Single Variant)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'No variants dropdown',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
url: '?q=',
|
||||
},
|
||||
{
|
||||
name: 'Search Results',
|
||||
url: '?q=gitbook',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-test="search-results"]');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-test="search-ask-answer"]');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GitBook Site (Multi Variants)',
|
||||
baseUrl: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Variants dropdown',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
const spaceDrowpdown = page.locator('[data-testid="space-dropdown-button"]');
|
||||
await spaceDrowpdown.waitFor();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Default variant',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
name: 'RFC variant',
|
||||
url: 'v/rfcs',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GitBook',
|
||||
baseUrl: 'https://docs.gitbook.com',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
url: '?q=',
|
||||
},
|
||||
{
|
||||
name: 'Search Results',
|
||||
url: '?q=gitbook',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-test="search-results"]');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-test="search-ask-answer"]');
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
{
|
||||
name: 'Not found',
|
||||
url: 'content-not-found',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Versioning',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Revision',
|
||||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PDF',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
tests: [
|
||||
{
|
||||
name: 'PDF',
|
||||
url: '~gitbook/pdf?limit=10',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Content tests',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Text',
|
||||
url: 'text-page',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Long text',
|
||||
url: 'text-page/long-text',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Images',
|
||||
url: 'blocks/block-images',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Inline Images',
|
||||
url: 'blocks/inline-images',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Tabs',
|
||||
url: 'blocks/tabs',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Hints',
|
||||
url: 'blocks/hints',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Integration Blocks',
|
||||
url: 'blocks/integrations',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Tables',
|
||||
url: 'blocks/tables',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Expandables',
|
||||
url: 'blocks/expandables',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'API Blocks',
|
||||
url: 'blocks/api-blocks',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Headings',
|
||||
url: 'blocks/headings',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Marks',
|
||||
url: 'blocks/marks',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Emojis',
|
||||
url: 'blocks/emojis',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Links',
|
||||
url: 'blocks/links',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Lists',
|
||||
url: 'blocks/lists',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Code',
|
||||
url: 'blocks/code',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Cards',
|
||||
url: 'blocks/cards',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
},
|
||||
{
|
||||
name: 'Embeds',
|
||||
url: 'blocks/embeds',
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Annotations',
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Page options',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
tests: [
|
||||
{
|
||||
name: 'With cover',
|
||||
url: 'page-options/page-with-cover',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With hero cover',
|
||||
url: 'page-options/page-with-hero-cover',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'With cover and no TOC',
|
||||
url: 'page-options/page-with-cover-and-no-toc',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Customization',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Without header',
|
||||
url: getCustomizationURL({
|
||||
header: {
|
||||
preset: CustomizationHeaderPreset.None,
|
||||
links: [],
|
||||
},
|
||||
}),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Share links',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-share-links/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Valid link',
|
||||
url: 'Fc6mMII9FKgnwm7qqynx/',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Invalid link',
|
||||
url: 'invalid/',
|
||||
run: async (page) => {
|
||||
await expect(
|
||||
page.getByText('Authentication missing to access this content'),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Space',
|
||||
baseUrl: `https://gitbook.gitbook.io/gbo-va-space/`,
|
||||
tests: [
|
||||
{
|
||||
name: 'First',
|
||||
url: (() => {
|
||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `first?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Second',
|
||||
url: (() => {
|
||||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `second?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Collection',
|
||||
baseUrl: `https://gitbook.gitbook.io/gbo-va-collection/`,
|
||||
tests: [
|
||||
{
|
||||
name: 'Root',
|
||||
url: (() => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Primary (Space A)',
|
||||
url: (() => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spacea?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Space B',
|
||||
url: (() => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spaceb?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Space C',
|
||||
url: (() => {
|
||||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `v/spacec?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Visitor Auth - Space (custom domain)',
|
||||
baseUrl: `https://test.gitbook.community/`,
|
||||
tests: [
|
||||
{
|
||||
name: 'Root',
|
||||
url: (() => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'First',
|
||||
url: (() => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `first?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Custom page',
|
||||
url: (() => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `custom-page?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
{
|
||||
name: 'Inner page',
|
||||
url: (() => {
|
||||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||||
const token = jwt.sign(
|
||||
{
|
||||
name: 'gitbook-open-tests',
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
expiresIn: '24h',
|
||||
},
|
||||
);
|
||||
return `custom-page/inner-page?jwt_token=${token}`;
|
||||
})(),
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Languages',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
tests: allLocales.map((locale) => ({
|
||||
name: locale,
|
||||
url: getCustomizationURL({
|
||||
internationalization: {
|
||||
locale,
|
||||
inherit: false,
|
||||
},
|
||||
}),
|
||||
run: async (page) => {
|
||||
const dialog = page.getByTestId('cookies-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
test.describe(testCase.name, () => {
|
||||
for (const testEntry of testCase.tests) {
|
||||
test(testEntry.name, async ({ page, baseURL }) => {
|
||||
const contentUrl = new URL(testEntry.url, testCase.baseUrl);
|
||||
const url = getContentTestURL(contentUrl.toString(), baseURL);
|
||||
await page.goto(url);
|
||||
if (testEntry.run) {
|
||||
await testEntry.run(page);
|
||||
}
|
||||
if (testEntry.screenshot !== false) {
|
||||
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
|
||||
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
|
||||
argosCSS: `
|
||||
/* Hide Intercom */
|
||||
.intercom-lightweight-app {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URL with customization settings.
|
||||
*/
|
||||
function getCustomizationURL(partial: Partial<CustomizationSettings>): string {
|
||||
const encoded = rison.encode_object(partial);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('customization', encoded);
|
||||
|
||||
return `?${searchParams.toString()}`;
|
||||
}
|
||||
@@ -7,11 +7,9 @@ module.exports = withSentryConfig(
|
||||
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT ?? 'development',
|
||||
GITBOOK_ASSETS_PREFIX: process.env.GITBOOK_ASSETS_PREFIX,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
GITBOOK_ICONS_TOKEN: process.env.GITBOOK_ICONS_TOKEN,
|
||||
},
|
||||
|
||||
webpack(config, { dev, webpack }) {
|
||||
webpack(config) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
@@ -21,21 +19,6 @@ module.exports = withSentryConfig(
|
||||
http: false,
|
||||
};
|
||||
|
||||
// Tree shake debug code for Sentry
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-with-nextjs
|
||||
if (!dev) {
|
||||
config.plugins.push(
|
||||
new webpack.DefinePlugin({
|
||||
__SENTRY_DEBUG__: false,
|
||||
// We always init Sentry with enableTracing: false for now, so this is useless
|
||||
__SENTRY_TRACING__: false,
|
||||
__RRWEB_EXCLUDE_IFRAME__: true,
|
||||
__RRWEB_EXCLUDE_SHADOW_DOM__: true,
|
||||
__SENTRY_EXCLUDE_REPLAY_WORKER__: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -62,9 +45,9 @@ module.exports = withSentryConfig(
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.gitbook.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
silent: true,
|
||||
+81
-24
@@ -1,35 +1,92 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:cloudflare": "next-on-pages",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"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"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"unit": "bun test {src,packages}/**/*.test.ts",
|
||||
"e2e": "playwright test",
|
||||
"postinstall": "rm -rf ./public/~gitbook/static/mathjax@3.2.2 && mkdir -p ./public/~gitbook/static/ && cp -R node_modules/mathjax/es5 ./public/~gitbook/static/mathjax@3.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"@geist-ui/icons": "^1.0.2",
|
||||
"@gitbook/api": "^0.46.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@upstash/redis": "^1.27.1",
|
||||
"ajv": "^8.12.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.0.7",
|
||||
"classnames": "^2.5.1",
|
||||
"content-security-policy-merger": "^1.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "^14.1.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.15.4",
|
||||
"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",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"recoil": "^0.7.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.0",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^1.2.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.4",
|
||||
"url-join": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^2.0.0",
|
||||
"@cloudflare/next-on-pages": "^1.9.0",
|
||||
"@cloudflare/workers-types": "^4.20231218.0",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/katex": "^0.16.5",
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/psi": "^4.1.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/rison": "^0.0.9",
|
||||
"autoprefixer": "^10",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "13.5.6",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.0.3",
|
||||
"psi": "^4.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
dist/
|
||||
@@ -1,18 +0,0 @@
|
||||
# @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
|
||||
@@ -1,22 +0,0 @@
|
||||
# `@gitbook/cache-do`
|
||||
|
||||
Cache backend, powered by Cloudflare Durable Objects. The cache is optimized for GitBook use-cases.
|
||||
|
||||
### Performances
|
||||
|
||||
The cache backend is optimized for performances by being distributed and accessible close to the worker locations that are reading it.
|
||||
|
||||
### Geo-distribution
|
||||
|
||||
To achieve a good balance between **performances** and **consistency**, cache objects are distributed over 7 locations, representing continents.
|
||||
|
||||
It makes it possible to purge all 7 locations in one go and achieve fast consistency.
|
||||
|
||||
### Concepts
|
||||
|
||||
**Cache tag**: unique tag in the cache environment. A cache tag groups multiple keys that should be purged together in one operation.
|
||||
Cache tags should not contain a large set of unique keys. Exceeding thousands could lead to performances or reliability issues.
|
||||
|
||||
**Cache key**: unique key in the cache environment. Each key should be assigned to a `tag`.
|
||||
|
||||
**Location**: cache is distributed over 7 unique locations, one for each continent.
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "@gitbook/cache-do",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/api.d.ts",
|
||||
"development": "./src/api.ts",
|
||||
"default": "./dist/api.js"
|
||||
}
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"lru_map": "^0.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"wrangler": "3.82.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "wrangler types --experimental-include-runtime",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
"release": "wrangler deploy",
|
||||
"release:preview": "wrangler deploy && wrangler deploy --env preview"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"bin",
|
||||
"data",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
]
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import { encode, decode } from '@msgpack/msgpack';
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import { LRUMap } from 'lru_map';
|
||||
|
||||
export interface CacheObjectDescriptor {
|
||||
get: <Value = unknown>(key: string) => Promise<Value | undefined>;
|
||||
set: <Value = unknown>(key: string, value: Value, expiresAt: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value stored in a chunked binary msgpack format.
|
||||
* Stored under the key `prop.${key}.${index}`.
|
||||
*/
|
||||
interface CacheObjectProp<Value = unknown> {
|
||||
value: Value;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expiration clock stored under the key `exp.${expiresAt}.${key}`.
|
||||
*/
|
||||
interface CacheObjectExp {
|
||||
/** Key of the property */
|
||||
k: string;
|
||||
/** Number of chunks */
|
||||
c: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Object class being deployed as a distributed cache.
|
||||
*/
|
||||
export class CacheObject extends DurableObject {
|
||||
private lru = new LRUMap<string, { match: CacheObjectProp | undefined }>(500);
|
||||
|
||||
/**
|
||||
* Open a descriptor to access the cache object.
|
||||
* The goal is to minimize the amount of RPC sessions between the client and the cache object.
|
||||
* One session is opened per request on the client side and used to perform multiple operations.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/rpc/#return-functions-from-rpc-methods
|
||||
*/
|
||||
public open(): CacheObjectDescriptor {
|
||||
return {
|
||||
get: async <Value = unknown>(key: string) => {
|
||||
return this.get<Value>(key);
|
||||
},
|
||||
set: async <Value = unknown>(key: string, value: Value, expiresAt: number) => {
|
||||
await this.set(key, value, expiresAt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a property.
|
||||
*/
|
||||
public async get<Value = unknown>(key: string) {
|
||||
return this.logOperation({ operation: 'get', key }, async (setLog) => {
|
||||
// Try the memory state first.
|
||||
const memoryEntry = this.lru.get(key);
|
||||
if (memoryEntry) {
|
||||
setLog({ memory: true });
|
||||
setLog({ memoryMatch: !!memoryEntry.match });
|
||||
if (!memoryEntry.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpired = memoryEntry.match.expiresAt < Date.now();
|
||||
setLog({ memoryExpired: isExpired });
|
||||
|
||||
if (!isExpired) {
|
||||
return memoryEntry.match.value as Value;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.getFromStorage<Value>(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a property from the DO storage.
|
||||
*/
|
||||
public async getFromStorage<Value = unknown>(key: string) {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache object.
|
||||
*/
|
||||
public async set<Value = unknown>(key: string, value: Value, expiresAt: number) {
|
||||
return this.logOperation({ operation: 'set', key }, async (setLog) => {
|
||||
const prop: CacheObjectProp<Value> = {
|
||||
value,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
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: chunks,
|
||||
};
|
||||
|
||||
await tx.put(getGCClockKey(key, expiresAt), clockValue);
|
||||
await tx.put(entries);
|
||||
|
||||
const currentAlarm = await tx.getAlarm();
|
||||
if (!currentAlarm) {
|
||||
// Set an alarm to garbage collect all entries that have expired in 12h.
|
||||
await tx.setAlarm(Date.now() + 12 * 60 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all keys in the cache object.
|
||||
*/
|
||||
public async purge() {
|
||||
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,
|
||||
});
|
||||
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();
|
||||
return Array.from(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm to garbage collect all entries that have expired.
|
||||
*/
|
||||
async alarm() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
} 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() {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStoragePropKey(key: string): string {
|
||||
return `prop.${key}.`;
|
||||
}
|
||||
|
||||
function getStoragePropChunkKey(key: string, index: number): string {
|
||||
return `${getStoragePropKey(key)}${index}`;
|
||||
}
|
||||
|
||||
function getGCClockRootKey(timestamp: number): string {
|
||||
return `exp.${timestamp}.`;
|
||||
}
|
||||
|
||||
function getGCClockKey(key: string, expiresAt: number): string {
|
||||
return `${getGCClockRootKey(expiresAt)}${key}`;
|
||||
}
|
||||
|
||||
function encodeChunks<T>(key: string, value: T): Record<string, Uint8Array> {
|
||||
const buf = encode(value);
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
const chunks = chunkUint8Array(buf, 128 * 1024);
|
||||
|
||||
for (let index = 0; index < chunks.length; index++) {
|
||||
entries[getStoragePropChunkKey(key, index)] = chunks[index];
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
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()!);
|
||||
return [index, value] as const;
|
||||
})
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, value]) => value);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buf = mergeUint8Array(chunks);
|
||||
return { value: decode(buf) as T, size: buf.length };
|
||||
}
|
||||
|
||||
function chunkUint8Array(input: Uint8Array, chunkSize: number): Uint8Array[] {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (let i = 0; i < input.length; i += chunkSize) {
|
||||
chunks.push(input.slice(i, i + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function mergeUint8Array(chunks: Uint8Array[]): Uint8Array {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { CacheObject, CacheObjectDescriptor } from './CacheObject';
|
||||
|
||||
export type CacheLocationId = ContinentCode;
|
||||
const allLocations: CacheLocationId[] = ['AF', 'AS', 'NA', 'SA', 'AN', 'EU', 'OC'];
|
||||
|
||||
/**
|
||||
* Location hint for the CacheObject durable object.
|
||||
*/
|
||||
const doLocationHints: {
|
||||
[key in CacheLocationId]: DurableObjectLocationHint;
|
||||
} = {
|
||||
AF: 'afr',
|
||||
AS: 'apac',
|
||||
NA: 'wnam',
|
||||
SA: 'sam',
|
||||
AN: 'oc',
|
||||
EU: 'weur',
|
||||
OC: 'oc',
|
||||
};
|
||||
|
||||
/**
|
||||
* Client to access a cache tag.
|
||||
*/
|
||||
export class CacheObjectStub {
|
||||
private stub: DurableObjectStub<CacheObject>;
|
||||
|
||||
constructor(
|
||||
/** Binding to the CacheObject durable object */
|
||||
private doNamespace: DurableObjectNamespace<CacheObject>,
|
||||
/** ID of the location to target */
|
||||
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 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() {
|
||||
return await this.stub.open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the cache.
|
||||
*/
|
||||
async get<Value = unknown>(key: string) {
|
||||
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) {
|
||||
return await this.stub.set(key, value, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all keys in the cache tag.
|
||||
*/
|
||||
async purge() {
|
||||
const keys = new Set<string>();
|
||||
await Promise.all(
|
||||
allLocations.map(async (locationId) => {
|
||||
const groupId = getCacheObjectIdName(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],
|
||||
});
|
||||
const locationkeys = await cacheGroup.purge();
|
||||
locationkeys.forEach((key) => keys.add(key));
|
||||
}),
|
||||
);
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheObjectIdName(locationId: CacheLocationId, tag: string): string {
|
||||
return `${locationId}:${tag}`;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './CacheObjectStub';
|
||||
@@ -1,9 +0,0 @@
|
||||
import { WorkerEntrypoint } from 'cloudflare:workers';
|
||||
|
||||
export * from './CacheObject';
|
||||
|
||||
export default class Worker extends WorkerEntrypoint {
|
||||
fetch() {
|
||||
return new Response('Hello, world!');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"incremental": true,
|
||||
"types": ["./.wrangler/types/runtime.d.ts"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
main = "./src/index.ts"
|
||||
name = "gitbook-open-cache"
|
||||
compatibility_date = "2024-09-02"
|
||||
|
||||
durable_objects.bindings = [
|
||||
{name = "CACHE", class_name = "CacheObject"}
|
||||
]
|
||||
|
||||
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
|
||||
@@ -1 +1 @@
|
||||
dist/
|
||||
index.ts
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# @gitbook/emoji-codepoints
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 57adb3e: Second release to fix publishing with changeset
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 5f8a8fe: Initial release
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import fs from 'fs';
|
||||
import emojisRaws from 'emoji-assets/emoji.json';
|
||||
|
||||
interface EmojiData {
|
||||
@@ -21,8 +20,7 @@ Object.entries(emojis).forEach(([key, value]) => {
|
||||
}
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.resolve(__dirname, 'dist'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, 'dist/index.ts'),
|
||||
'index.ts',
|
||||
`export const emojiCodepoints: Record<string, string> = ${JSON.stringify(output, null, 4)};`,
|
||||
);
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"name": "@gitbook/emoji-codepoints",
|
||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"exports": "./dist/index.ts",
|
||||
"exports": "./index.ts",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"emoji-assets": "^8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "bun ./build.ts",
|
||||
"clean": "rm -rf ./dist"
|
||||
"postinstall": "bun ./build.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# visual tests
|
||||
screenshots/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Generated public files
|
||||
/public/~gitbook/static/*
|
||||
!/public/~gitbook/static/images
|
||||
@@ -1,182 +0,0 @@
|
||||
# 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
|
||||
|
||||
- 24b785c: Update shiki for code block syntax highlighting, with support for more languages and fixes for diffs. It also patches the deployment on Cloudflare to support edge functions larger than 4MB.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- acc3f2f: Fix error with the "Try it" of OpenAPI block because of the Scalar proxy failing on Cloudflare with the `cache` option
|
||||
- Updated dependencies [709f1a1]
|
||||
- Updated dependencies [ede2335]
|
||||
- Updated dependencies [0426312]
|
||||
- @gitbook/react-openapi@0.6.0
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3445db4]
|
||||
- @gitbook/react-contentkit@0.5.0
|
||||
- @gitbook/react-openapi@0.5.0
|
||||
- @gitbook/react-math@0.5.0
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [24cd72e]
|
||||
- @gitbook/react-contentkit@0.4.0
|
||||
- @gitbook/react-math@0.4.0
|
||||
- @gitbook/react-openapi@0.4.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- de747b7: Refactor the repository to be a proper monorepo and publish JS files on NPM instead of TypeScript files.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [de747b7]
|
||||
- Updated dependencies [de747b7]
|
||||
- @gitbook/react-contentkit@0.3.0
|
||||
- @gitbook/react-openapi@0.3.0
|
||||
- @gitbook/react-math@0.3.0
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"exclude": ["/~gitbook/static/*"]
|
||||
}
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
import type { CacheObject } from '@gitbook/cache-do';
|
||||
|
||||
declare global {
|
||||
interface CloudflareEnv {
|
||||
CACHE?: DurableObjectNamespace<CacheObject>;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "env-cmd --silent -f ../../.env.local next dev",
|
||||
"build": "next build",
|
||||
"build:cloudflare": "next-on-pages --custom-entrypoint=./src/cloudflare-entrypoint.ts",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"e2e": "playwright test",
|
||||
"unit": "bun test {src,packages}",
|
||||
"generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math",
|
||||
"copy:icons": "gitbook-icons ./public/~gitbook/static/icons",
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.81.0",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"@gitbook/react-contentkit": "workspace:*",
|
||||
"@gitbook/react-math": "workspace:*",
|
||||
"@gitbook/react-openapi": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@sentry/nextjs": "^7.94.1",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@upstash/redis": "^1.27.1",
|
||||
"ajv": "^8.12.0",
|
||||
"assert-never": "^1.2.1",
|
||||
"bun-types": "^1.1.20",
|
||||
"classnames": "^2.5.1",
|
||||
"content-security-policy-merger": "^1.0.0",
|
||||
"framer-motion": "^10.16.14",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsontoxml": "^1.0.1",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.17.4",
|
||||
"object-hash": "^3.0.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"p-map": "^7.0.0",
|
||||
"parse-cache-control": "^1.0.1",
|
||||
"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",
|
||||
"rehype-stringify": "^10.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.0",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^1.23.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwind-shades": "^1.1.2",
|
||||
"unified": "^11.0.4",
|
||||
"url-join": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^2.0.0",
|
||||
"@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",
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
"@types/psi": "^4.1.6",
|
||||
"@types/react": "18.3.13",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/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",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"postcss": "^8",
|
||||
"psi": "^4.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"ts-essentials": "^10.0.1",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# GitBook immutable static assets
|
||||
/~gitbook/static/*
|
||||
cache-control: public,max-age=31536000,immutable
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
BrowserClient,
|
||||
makeFetchTransport,
|
||||
defaultStackParser,
|
||||
getCurrentScope,
|
||||
} from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (dsn) {
|
||||
// To tree shake default integrations that we don't use
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/#tree-shaking-default-integrations
|
||||
const client = new BrowserClient({
|
||||
debug: false,
|
||||
dsn,
|
||||
integrations: [],
|
||||
sampleRate: 0.1,
|
||||
enableTracing: false,
|
||||
beforeSendTransaction: () => {
|
||||
return null;
|
||||
},
|
||||
transport: makeFetchTransport,
|
||||
stackParser: defaultStackParser,
|
||||
});
|
||||
|
||||
getCurrentScope().setClient(client);
|
||||
client.init();
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { useScrollPage } from '@/components/hooks';
|
||||
|
||||
/**
|
||||
* Client component to initialize interactivity for a page.
|
||||
*/
|
||||
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 a hash or to the top of the page
|
||||
useScrollPage({ scrollMarginTop: props.withSections ? 50 : undefined });
|
||||
|
||||
useStripFallbackQueryParam();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the fallback query parameter from current URL.
|
||||
*
|
||||
* When the user switches variants using the space dropdown, we pass a fallback=true parameter.
|
||||
* This parameter indicates that we should redirect to the root page if the path from the
|
||||
* previous variant doesn't exist in the new variant. If the path does exist, no redirect occurs,
|
||||
* so we need to remove the fallback parameter.
|
||||
*/
|
||||
function useStripFallbackQueryParam() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchParams.has('fallback')) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('fallback');
|
||||
router.push(`${pathname}?${params.toString()}${window.location.hash ?? ''}`);
|
||||
}
|
||||
}, [router, pathname, searchParams]);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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,38 +0,0 @@
|
||||
import { SpaceIntegrationScript } from '@gitbook/api';
|
||||
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import { getSiteData, getSpaceCustomization } from '@/lib/api';
|
||||
|
||||
import { getSiteOrSpacePointerForPDF } from './pointer';
|
||||
|
||||
/**
|
||||
* Layout to be used for the site root. It fetches the customization data for the
|
||||
* site or space and initializes the CustomizationRootLayout with it.
|
||||
*/
|
||||
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
const pointer = getSiteOrSpacePointerForPDF();
|
||||
const { customization } = await ('siteId' in pointer
|
||||
? getSiteData(pointer)
|
||||
: getSpaceLayoutData(pointer.spaceId));
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the layout data about a space at once.
|
||||
*/
|
||||
async function getSpaceLayoutData(spaceId: string) {
|
||||
const [{ customization }, scripts] = await Promise.all([
|
||||
getSpaceCustomization(spaceId),
|
||||
[] as SpaceIntegrationScript[],
|
||||
]);
|
||||
|
||||
return {
|
||||
customization,
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
|
||||
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
|
||||
|
||||
/**
|
||||
* PDF generation can be done at the site level (e.g. docs.foo.com/~gitbook/pdf) or
|
||||
* at the space level (e.g. open.gitbook.com/~space/:spaceId/~gitbook/pdf) which is
|
||||
* for PDF export of in-app private spaces.
|
||||
*
|
||||
* This function returns the pointer depending on the context.
|
||||
*/
|
||||
export function getSiteOrSpacePointerForPDF(): SiteContentPointer | SpaceContentPointer {
|
||||
try {
|
||||
return getSiteContentPointer();
|
||||
} catch (error) {
|
||||
return getSpacePointer();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// @ts-ignore
|
||||
import nextOnPagesHandler from '@cloudflare/next-on-pages/fetch-handler';
|
||||
|
||||
import { withMiddlewareHeadersStorage } from './lib/middleware';
|
||||
|
||||
/**
|
||||
* We use a custom entrypoint until we can move to opennext (https://github.com/opennextjs/opennextjs-cloudflare/issues/92).
|
||||
* There is a bug in next-on-pages where headers can't be set on the response in the middleware for RSC requests (https://github.com/cloudflare/next-on-pages/issues/897).
|
||||
*/
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const response = await withMiddlewareHeadersStorage(() =>
|
||||
nextOnPagesHandler.fetch(request, env, ctx),
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
} as ExportedHandler<{ ASSETS: Fetcher }>;
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||
import { DateRelative } from '../primitives';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
content: SiteContentPointer;
|
||||
space: Space;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||
*/
|
||||
export function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { content } = props;
|
||||
|
||||
const toolbar = (() => {
|
||||
if (content.changeRequestId) {
|
||||
return (
|
||||
<ChangeRequestToolbar
|
||||
spaceId={content.spaceId}
|
||||
changeRequestId={content.changeRequestId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (content.revisionId) {
|
||||
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!toolbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'fixed',
|
||||
'bottom-5',
|
||||
'left-1/2',
|
||||
'z-50',
|
||||
'transform',
|
||||
'-translate-x-1/2',
|
||||
'rounded-full',
|
||||
'bg-dark-1/9',
|
||||
'shadow-lg',
|
||||
'min-h-10',
|
||||
'min-w-40',
|
||||
'p-2',
|
||||
'max-w-md',
|
||||
'border-dark-1',
|
||||
'backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={null}>{toolbar}</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
|
||||
const { spaceId, changeRequestId } = props;
|
||||
|
||||
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="code-branch" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
||||
</p>
|
||||
<p className="text-xs text-light/8 dark:text-light/8">
|
||||
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
||||
</p>
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<RefreshChangeRequestButton
|
||||
spaceId={spaceId}
|
||||
changeRequestId={changeRequestId}
|
||||
revisionId={changeRequest.revision}
|
||||
updatedAt={new Date(changeRequest.updatedAt).getTime()}
|
||||
/>
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
|
||||
const { spaceId, revisionId } = props;
|
||||
|
||||
const revision = await getRevision(spaceId, revisionId, {
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="code-commit" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
Revision created <DateRelative value={revision.createdAt} />
|
||||
</p>
|
||||
{revision.git ? (
|
||||
<p className="text-xs text-light/8 dark:text-light/8">{revision.git.message}</p>
|
||||
) : null}
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
{revision.git?.url ? (
|
||||
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
||||
<Icon
|
||||
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
||||
className="size-4"
|
||||
/>
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ToolbarButton } from './Toolbar';
|
||||
|
||||
// We don't show the button if the content has been updated 30s ago or less.
|
||||
const minInterval = 1000 * 30; // 5 minutes
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshChangeRequestButton(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const { updatedAt } = props;
|
||||
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const checkForUpdates = useCheckForContentUpdate(props);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setVisible(false);
|
||||
}
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Show the button if the content has been updated more than 30s ago.
|
||||
React.useEffect(() => {
|
||||
if (updatedAt < Date.now() - minInterval) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [updatedAt]);
|
||||
|
||||
// 30sec after being hidden, we show the button again
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, minInterval);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function Toolbar(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-4',
|
||||
'text-sm',
|
||||
'px-4',
|
||||
'py-1',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-light',
|
||||
'dark:text-light',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarBody(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-col gap-1">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-row gap-2">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'size-11',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'hover:bg-dark-1',
|
||||
'hover:text-white',
|
||||
'truncate',
|
||||
'text-light',
|
||||
'dark:text-light',
|
||||
'dark:hover:bg-dark-2',
|
||||
'hover:shadow-lg',
|
||||
'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { renderAd } from './renderAd';
|
||||
|
||||
/**
|
||||
* Zone ID provided by BuySellAds for the preview.
|
||||
*/
|
||||
const PREVIEW_ZONE_ID = 'CVAIKKQM';
|
||||
|
||||
/**
|
||||
* Fetch and render the Ad placement.
|
||||
* https://docs.buysellads.com/ad-serving-api
|
||||
*/
|
||||
export function Ad({
|
||||
zoneId,
|
||||
spaceId,
|
||||
placement,
|
||||
ignore,
|
||||
siteAdsStatus,
|
||||
style,
|
||||
mode = 'auto',
|
||||
}: {
|
||||
zoneId: string | null;
|
||||
spaceId: string;
|
||||
placement: string;
|
||||
ignore: boolean;
|
||||
style?: ClassValue;
|
||||
siteAdsStatus?: SiteAds['status'];
|
||||
mode?: 'classic' | 'auto' | 'cover';
|
||||
}) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
|
||||
|
||||
// Observe the container visibility
|
||||
React.useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// When the container is visible,
|
||||
// track an impression on the ad and fetch it
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const previewParam = new URL(window.location.href).searchParams.get('ads_preview');
|
||||
const preview = !!previewParam;
|
||||
const realZoneId = preview ? PREVIEW_ZONE_ID : zoneId;
|
||||
const showPlaceholderAd =
|
||||
previewParam === 'placeholder' ||
|
||||
(siteAdsStatus &&
|
||||
(siteAdsStatus === SiteAdsStatus.Pending ||
|
||||
siteAdsStatus === SiteAdsStatus.InReview));
|
||||
|
||||
if (!realZoneId && !showPlaceholderAd) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const result = showPlaceholderAd
|
||||
? await renderAd({ source: 'placeholder' })
|
||||
: realZoneId
|
||||
? await renderAd({
|
||||
placement,
|
||||
ignore: ignore || preview,
|
||||
zoneId: realZoneId,
|
||||
mode,
|
||||
source: 'live',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
setAd(result);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
|
||||
{ad ? (
|
||||
<>
|
||||
{ad}
|
||||
<AdSponsoredLink spaceId={spaceId} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdSponsoredLink(props: { spaceId: string }) {
|
||||
const { spaceId } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
viaUrl.searchParams.set('utm_medium', 'sponsored-by-gitbook');
|
||||
viaUrl.searchParams.set('utm_campaign', spaceId);
|
||||
|
||||
return (
|
||||
<p
|
||||
className={tcls(
|
||||
'mt-2',
|
||||
'mr-2',
|
||||
'text-xs',
|
||||
'text-right',
|
||||
'text-dark/5',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
|
||||
{t(language, 'sponsored_via_gitbook')}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdItem } from './types';
|
||||
|
||||
/**
|
||||
* Classic rendering for an ad.
|
||||
*/
|
||||
export function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
return (
|
||||
<a
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-4',
|
||||
'bg-light-2',
|
||||
'text-dark/7',
|
||||
'dark:bg-dark-2',
|
||||
'dark:text-light/7',
|
||||
'hover:text-dark/9',
|
||||
'dark:hover:text-light/9',
|
||||
'rounded-lg',
|
||||
'p-4',
|
||||
)}
|
||||
href={ad.statlink}
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{'smallImage' in ad ? (
|
||||
<div>
|
||||
<img
|
||||
alt="Ads logo"
|
||||
className={tcls('rounded-md')}
|
||||
src={getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={tcls('px-6', 'py-4', 'rounded-md')}
|
||||
style={{ backgroundColor: ad.backgroundColor }}
|
||||
>
|
||||
<img
|
||||
alt="Ads logo"
|
||||
src={getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={tcls('flex', 'flex-col')}>
|
||||
<div className={tcls('text-xs')}>{ad.description}</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { hexToRgba } from '@/lib/colors';
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdCover } from './types';
|
||||
|
||||
/**
|
||||
* Cover rendering for an ad.
|
||||
*/
|
||||
export function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
const largeImage = getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||
|
||||
return (
|
||||
<a
|
||||
className={tcls(
|
||||
'group/ad',
|
||||
'relative',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'gap-4',
|
||||
'bg-light-2',
|
||||
'text-dark/7',
|
||||
'dark:bg-dark-2',
|
||||
'dark:text-light/7',
|
||||
'hover:text-dark/9',
|
||||
'dark:hover:text-light/9',
|
||||
'rounded-lg',
|
||||
'p-4',
|
||||
'overflow-hidden',
|
||||
'shadow-sm',
|
||||
)}
|
||||
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
|
||||
href={ad.statlink}
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'bg-center',
|
||||
'bg-cover',
|
||||
'bg-no-repeat',
|
||||
'z-0',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${largeImage})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={tcls('z-[2]')}>
|
||||
<img
|
||||
alt="Large image"
|
||||
src={largeImage}
|
||||
className={tcls(
|
||||
'rounded-md',
|
||||
'shadow-md',
|
||||
'max-h-32',
|
||||
'group-hover/ad:max-h-16',
|
||||
'transition-all',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={tcls('z-[2]')}>
|
||||
<img alt={ad.company} src={ad.logo} className={tcls('max-w-36', 'max-h-12')} />
|
||||
</div>
|
||||
<div className={tcls('flex', 'flex-col', 'z-[2]')}>
|
||||
<div className={tcls('text-sm', 'font-semibold', 'mb-2')}>{ad.companyTagline}</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'h-0',
|
||||
'opacity-0',
|
||||
'group-hover/ad:h-16',
|
||||
'group-hover/ad:opacity-10',
|
||||
'transition-all',
|
||||
)}
|
||||
>
|
||||
{ad.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={tcls('z-[2]')}>
|
||||
<span
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
'font-semibold',
|
||||
'shadow-lg',
|
||||
'rounded-md',
|
||||
'bg-white',
|
||||
'py-2',
|
||||
'px-4',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: ad.ctaBackgroundColor,
|
||||
color: ad.ctaTextColor ?? ad.backgroundColor,
|
||||
}}
|
||||
>
|
||||
{ad.callToAction}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={tcls('absolute', 'inset-0', 'backdrop-blur', 'z-[1]')}
|
||||
style={{
|
||||
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Render attribution or verification pixels.
|
||||
* https://docs.buysellads.com/ad-serving-api#pixels
|
||||
*/
|
||||
export function AdPixels({ rawPixel }: { rawPixel: string }) {
|
||||
const pixels = rawPixel.split('||');
|
||||
const time = String(Math.round(Date.now() / 1e4) | 0);
|
||||
|
||||
return (
|
||||
<div className={tcls('hidden')}>
|
||||
{pixels.map((pixel, index) => {
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={pixel.replace('[timestamp]', time)}
|
||||
width="1"
|
||||
height="1"
|
||||
style={{ display: 'none' }}
|
||||
alt="Ads tracking pixel"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<svg width="193" height="120" viewBox="0 0 193 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M183.432 0.776855H10.2788C5.07442 0.776855 0.855469 4.14309 0.855469 8.29556V111.678C0.855469 115.831 5.07442 119.197 10.2788 119.197H183.432C188.637 119.197 192.855 115.831 192.855 111.678V8.29556C192.855 4.14309 188.637 0.776855 183.432 0.776855Z"
|
||||
fill="#14171C" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M96.8556 72.2159C84.724 72.2159 74.8894 82.0505 74.8894 94.1818C74.8894 95.9661 73.4431 97.4124 71.659 97.4124C69.875 97.4124 68.4287 95.9661 68.4287 94.1818C68.4287 78.4825 81.1558 65.7554 96.8556 65.7554C112.556 65.7554 125.282 78.4825 125.282 94.1818C125.282 95.9661 123.836 97.4124 122.053 97.4124C120.268 97.4124 118.822 95.9661 118.822 94.1818C118.822 82.0505 108.987 72.2159 96.8556 72.2159Z"
|
||||
fill="url(#paint0_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M129.621 94.1816C129.621 76.0865 114.951 61.4172 96.8558 61.4172C78.7603 61.4172 64.091 76.0865 64.091 94.1816C64.091 95.9658 62.6447 97.4122 60.8607 97.4122C59.0766 97.4122 57.6304 95.9658 57.6304 94.1816C57.6304 72.5183 75.1922 54.9565 96.8558 54.9565C118.52 54.9565 136.081 72.5183 136.081 94.1816C136.081 95.9658 134.635 97.4122 132.85 97.4122C131.067 97.4122 129.621 95.9658 129.621 94.1816Z"
|
||||
fill="url(#paint1_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M96.856 50.3419C72.6438 50.3419 53.0159 69.9698 53.0159 94.1818C53.0159 95.966 51.5695 97.4124 49.7855 97.4124C48.0014 97.4124 46.5552 95.966 46.5552 94.1818C46.5552 66.4018 69.0756 43.8813 96.856 43.8813C124.637 43.8813 147.157 66.4018 147.157 94.1818C147.157 95.966 145.711 97.4124 143.927 97.4124C142.142 97.4124 140.696 95.966 140.696 94.1818C140.696 69.9698 121.068 50.3419 96.856 50.3419Z"
|
||||
fill="url(#paint2_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M151.587 94.1815C151.587 63.9548 127.083 39.4509 96.8557 39.4509C66.6286 39.4509 42.1246 63.9548 42.1246 94.1815C42.1246 95.9658 40.6784 97.4121 38.8943 97.4121C37.1103 97.4121 35.6641 95.9658 35.6641 94.1815C35.6641 60.3866 63.0605 32.9902 96.8557 32.9902C130.651 32.9902 158.047 60.3866 158.047 94.1815C158.047 95.9658 156.601 97.4121 154.818 97.4121C153.033 97.4121 151.587 95.9658 151.587 94.1815Z"
|
||||
fill="url(#paint3_radial_3089_1090)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M96.8554 29.0217C60.8683 29.0217 31.6951 58.195 31.6951 94.1817C31.6951 95.966 30.2488 97.4123 28.4647 97.4123C26.6807 97.4123 25.2344 95.966 25.2344 94.1817C25.2344 54.6269 57.3003 22.561 96.8554 22.561C136.411 22.561 168.477 54.6269 168.477 94.1817C168.477 95.966 167.03 97.4123 165.246 97.4123C163.462 97.4123 162.015 95.966 162.015 94.1817C162.015 58.195 132.842 29.0217 96.8554 29.0217Z"
|
||||
fill="url(#paint4_radial_3089_1090)" />
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(88.9053 80.4777) rotate(64.8515) scale(18.7082 33.8612)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(143.478 58.0317) rotate(168.06) scale(87.7455 57.2588)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.19" stop-color="#F4E28D" />
|
||||
<stop offset="0.614011" stop-color="#BBDDE5" />
|
||||
<stop offset="1" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(82.7879 68.7762) rotate(63.8366) scale(31.9052 59.4091)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint3_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(101.132 56.1925) rotate(172.165) scale(66.0854 41.9909)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_3089_1090" cx="0" cy="0" r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(76.8246 57.371) rotate(63.4233) scale(44.7721 84.288)">
|
||||
<stop stop-color="#3F89A1" />
|
||||
<stop offset="0.339725" stop-color="#F4E28D" />
|
||||
<stop offset="0.505624" stop-color="#BBDDE5" />
|
||||
<stop offset="0.848958" stop-color="#FDA599" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -1 +0,0 @@
|
||||
export * from './Ad';
|
||||
@@ -1,134 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
import { AdCoverRendering } from './AdCoverRendering';
|
||||
import { AdPixels } from './AdPixels';
|
||||
import adRainbow from './assets/ad-rainbow.svg';
|
||||
import { AdItem, AdsResponse } from './types';
|
||||
|
||||
type FetchAdOptions = FetchLiveAdOptions | FetchPlaceholderAdOptions;
|
||||
|
||||
interface FetchLiveAdOptions {
|
||||
/**
|
||||
* Source of the ad (live: from the platform)
|
||||
*/
|
||||
source: 'live';
|
||||
/** ID of the zone to fetch Ads for */
|
||||
zoneId: string;
|
||||
/** Mode to render the Ad */
|
||||
mode: 'classic' | 'auto' | 'cover';
|
||||
/** Name of the placement for the ad */
|
||||
placement: string;
|
||||
/** If true, we'll not track it as an impression */
|
||||
ignore: boolean;
|
||||
}
|
||||
|
||||
interface FetchPlaceholderAdOptions {
|
||||
/**
|
||||
* Source of the ad (placeholder: static placeholder ad)
|
||||
*/
|
||||
source: 'placeholder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to render the Ad placement.
|
||||
* We use a server-action to avoid caching issues with server-side components,
|
||||
* and properly access user-agent and IP.
|
||||
*/
|
||||
export async function renderAd(options: FetchAdOptions) {
|
||||
const mode = options.source === 'live' ? options.mode : 'classic';
|
||||
|
||||
const result = options.source === 'live' ? await fetchAd(options) : getPlaceholderAd();
|
||||
if (!result || !result.ad.description || !result.ad.statlink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ad } = result;
|
||||
|
||||
return (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAd({
|
||||
zoneId,
|
||||
placement,
|
||||
ignore,
|
||||
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
|
||||
const { ip, userAgent } = getUserAgentAndIp();
|
||||
|
||||
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
|
||||
url.searchParams.set('segment', `placement:${placement}`);
|
||||
url.searchParams.set('v', 'true');
|
||||
url.searchParams.set('forwardedip', ip);
|
||||
url.searchParams.set('useragent', userAgent);
|
||||
if (ignore) {
|
||||
url.searchParams.set('ignore', 'true');
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
const json: AdsResponse = await res.json();
|
||||
|
||||
const first = json.ads[0];
|
||||
if (first && 'active' in first) {
|
||||
return { ad: first, ip };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPlaceholderAd(): { ad: AdItem; ip: string } {
|
||||
const { ip } = getUserAgentAndIp();
|
||||
|
||||
return {
|
||||
ad: {
|
||||
active: '1',
|
||||
ad_via_link: '',
|
||||
bannerid: '',
|
||||
creativeid: '',
|
||||
description:
|
||||
'Your docs could be this good.\nPublish incredible open source docs for free with GitBook',
|
||||
evenodd: '0',
|
||||
external_id: '',
|
||||
height: '0',
|
||||
i: '0',
|
||||
identifier: '',
|
||||
longimp: '',
|
||||
longlink: '',
|
||||
num_slots: '1',
|
||||
rendering: 'carbon',
|
||||
smallImage: adRainbow.src,
|
||||
statimp: '',
|
||||
statlink:
|
||||
'https://www.gitbook.com/solutions/open-source?utm_campaign=sponsored-content&utm_medium=ad&utm_source=content',
|
||||
timestamp: Date.now().toString(),
|
||||
width: '0',
|
||||
zoneid: '',
|
||||
zonekey: '',
|
||||
},
|
||||
ip,
|
||||
};
|
||||
}
|
||||
|
||||
function getUserAgentAndIp() {
|
||||
const headersSet = headers();
|
||||
const ip =
|
||||
headersSet.get('x-gitbook-ipv4') ??
|
||||
headersSet.get('x-gitbook-ip') ??
|
||||
headersSet.get('cf-pseudo-ipv4') ??
|
||||
headersSet.get('cf-connecting-ip') ??
|
||||
headersSet.get('x-forwarded-for') ??
|
||||
'';
|
||||
const userAgent = headersSet.get('user-agent') ?? '';
|
||||
|
||||
return { ip, userAgent };
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
export interface AdGeneric {
|
||||
active: string;
|
||||
ad_via_link: string;
|
||||
bannerid: string;
|
||||
creativeid: string;
|
||||
evenodd: string;
|
||||
external_id: string;
|
||||
height: string;
|
||||
i: string;
|
||||
identifier: string;
|
||||
longimp: string;
|
||||
longlink: string;
|
||||
num_slots: string;
|
||||
statimp: string;
|
||||
statlink: string;
|
||||
timestamp: string;
|
||||
width: string;
|
||||
zoneid: string;
|
||||
zonekey: string;
|
||||
rendering: 'carbon';
|
||||
pixel?: string;
|
||||
}
|
||||
|
||||
export interface AdClassic extends AdGeneric {
|
||||
description: string;
|
||||
smallImage: string;
|
||||
}
|
||||
|
||||
export interface AdCover extends AdGeneric {
|
||||
backgroundColor: string;
|
||||
backgroundHoverColor?: string;
|
||||
textColor?: string;
|
||||
textColorHover?: string;
|
||||
callToAction: string;
|
||||
company: string;
|
||||
companyTagline: string;
|
||||
description: string;
|
||||
largeImage: string;
|
||||
image?: string;
|
||||
logo: string;
|
||||
ctaBackgroundColor?: string;
|
||||
ctaBackgroundHoverColor?: string;
|
||||
ctaTextColor?: string;
|
||||
ctaTextColorHover?: string;
|
||||
}
|
||||
|
||||
export type AdItem = AdClassic | AdCover;
|
||||
|
||||
export interface AdsResponse {
|
||||
ads: Array<AdItem | {}>;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './useCheckForContentUpdate';
|
||||
@@ -1,15 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getChangeRequest } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Return true if a change-request has been updated.
|
||||
*/
|
||||
export async function hasContentBeenUpdated(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { hasContentBeenUpdated } from './server-actions';
|
||||
|
||||
/**
|
||||
* Return a callback to check if a change request has been updated and to refresh the page if it has.
|
||||
*/
|
||||
export function useCheckForContentUpdate(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
const { spaceId, changeRequestId, revisionId } = props;
|
||||
|
||||
return React.useCallback(async () => {
|
||||
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
|
||||
|
||||
if (updated) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [spaceId, changeRequestId, revisionId]);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { DocumentBlockContentRef } from '@gitbook/api';
|
||||
|
||||
import { Card, Emoji } from '@/components/primitives';
|
||||
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
|
||||
import { ResolvedContentRef } from '@/lib/references';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { SpaceIcon } from '../Space/SpaceIcon';
|
||||
|
||||
export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
const resolved = await context.resolveContentRef(block.data.ref, {
|
||||
resolveAnchorText: true,
|
||||
iconStyle: ['text-xl', 'text-dark/6', 'dark:text-light/6'],
|
||||
});
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isContentInOtherSpace =
|
||||
context.contentRefContext?.space &&
|
||||
'space' in block.data.ref &&
|
||||
context.contentRefContext.space.id !== block.data.ref.space;
|
||||
const kind = block?.data?.ref?.kind;
|
||||
if ((resolved.active && kind === 'space') || isContentInOtherSpace) {
|
||||
return <SpaceRefCard {...props} resolved={resolved} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
leadingIcon={resolved.icon ? resolved.icon : null}
|
||||
href={resolved.href}
|
||||
title={resolved.text}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function SpaceRefCard(
|
||||
props: { resolved: ResolvedContentRef } & BlockProps<DocumentBlockContentRef>,
|
||||
) {
|
||||
const { context, style, resolved } = props;
|
||||
const spaceId = context.contentRefContext?.space.id;
|
||||
|
||||
if (!spaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { customization: spaceCustomization } = getSpaceCustomization(spaceId);
|
||||
const customFavicon = spaceCustomization?.favicon;
|
||||
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
|
||||
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
|
||||
|
||||
return (
|
||||
<Card
|
||||
leadingIcon={
|
||||
<SpaceIcon
|
||||
icon={customIcon}
|
||||
emoji={customEmoji}
|
||||
alt=""
|
||||
sizes={[{ width: 24 }]}
|
||||
style={['object-contain', 'size-6']}
|
||||
/>
|
||||
}
|
||||
href={resolved.href}
|
||||
title={resolved.text}
|
||||
postTitle={resolved.subText}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { DocumentBlock, JSONDocument } from '@gitbook/api';
|
||||
|
||||
import { tcls, ClassValue } from '@/lib/tailwind';
|
||||
|
||||
import { Block } from './Block';
|
||||
import { DocumentContextProps } from './DocumentView';
|
||||
import { isBlockOffscreen } from './utils';
|
||||
|
||||
/**
|
||||
* Renders a list of blocks with a wrapper element.
|
||||
*/
|
||||
export function Blocks<TBlock extends DocumentBlock, Tag extends React.ElementType = 'div'>(
|
||||
props: UnwrappedBlocksProps<TBlock> & {
|
||||
/** HTML tag to use for the wrapper */
|
||||
tag?: Tag;
|
||||
|
||||
/** Style passed to the wrapper */
|
||||
style?: ClassValue;
|
||||
|
||||
/** Props to pass to the wrapper element */
|
||||
wrapperProps?: React.ComponentProps<Tag>;
|
||||
},
|
||||
) {
|
||||
const { tag: Tag = 'div', style, wrapperProps, ...blocksProps } = props;
|
||||
|
||||
return (
|
||||
<Tag {...wrapperProps} className={tcls(style)}>
|
||||
<UnwrappedBlocks {...blocksProps} />
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
type UnwrappedBlocksProps<TBlock extends DocumentBlock> = DocumentContextProps & {
|
||||
/** Blocks to render */
|
||||
nodes: TBlock[];
|
||||
|
||||
/** Document being rendered */
|
||||
document: JSONDocument;
|
||||
|
||||
/** Ancestors of the blocks */
|
||||
ancestorBlocks: DocumentBlock[];
|
||||
|
||||
/** Style passed to all blocks */
|
||||
blockStyle?: ClassValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a list of blocks without a wrapper element.
|
||||
*/
|
||||
export function UnwrappedBlocks<TBlock extends DocumentBlock>(props: UnwrappedBlocksProps<TBlock>) {
|
||||
const { nodes, blockStyle, ...contextProps } = props;
|
||||
|
||||
let isOffscreen = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => {
|
||||
isOffscreen =
|
||||
isOffscreen ||
|
||||
isBlockOffscreen({
|
||||
document: props.document,
|
||||
block: node,
|
||||
ancestorBlocks: props.ancestorBlocks,
|
||||
});
|
||||
|
||||
return (
|
||||
<Block
|
||||
key={node.key}
|
||||
block={node}
|
||||
style={[
|
||||
'w-full mx-auto decoration-primary/6',
|
||||
node.data && 'fullWidth' in node.data && node.data.fullWidth
|
||||
? 'max-w-screen-xl'
|
||||
: 'max-w-3xl',
|
||||
blockStyle,
|
||||
]}
|
||||
isEstimatedOffscreen={isOffscreen}
|
||||
{...contextProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useHash } from '@/components/hooks';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Details component rendered on client so it can expand dependent on url hash changes.
|
||||
*/
|
||||
export function Details(props: {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
contentIds?: string[];
|
||||
open?: boolean;
|
||||
className?: ClassValue;
|
||||
}) {
|
||||
const { children, id, className } = props;
|
||||
|
||||
const detailsRef = React.useRef<HTMLDetailsElement>(null);
|
||||
|
||||
const [openFromHash, setOpenFromHash] = React.useState(false);
|
||||
|
||||
const hash = useHash();
|
||||
/**
|
||||
* Open the details element if the url hash refers to the id of the details element
|
||||
* or the id of some element contained within the details element.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!hash || !detailsRef.current) {
|
||||
return;
|
||||
}
|
||||
if (hash === id) {
|
||||
setOpenFromHash(true);
|
||||
}
|
||||
const activeElement = document.getElementById(hash);
|
||||
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
|
||||
}, [hash, id]);
|
||||
|
||||
return (
|
||||
<details
|
||||
ref={detailsRef}
|
||||
id={id}
|
||||
open={props.open || openFromHash}
|
||||
className={tcls(
|
||||
className,
|
||||
'group/expandable',
|
||||
'shadow-dark/1',
|
||||
'bg-gradient-to-t',
|
||||
'from-light-1',
|
||||
'to-light-1',
|
||||
'border',
|
||||
'border-b-0',
|
||||
'border-dark-3/3',
|
||||
//all
|
||||
'[&]:mt-[0px]',
|
||||
//select first child
|
||||
'[&:first-child]:mt-5',
|
||||
'[&:first-child]:rounded-t-lg',
|
||||
//select first in group
|
||||
'[:not(&)_+&]:mt-5',
|
||||
'[:not(&)_+&]:rounded-t-lg',
|
||||
//select last in group
|
||||
'[&:not(:has(+_&))]:mb-5',
|
||||
'[&:not(:has(+_&))]:rounded-b-lg',
|
||||
'[&:not(:has(+_&))]:border-b',
|
||||
/* '[&:not(:has(+_&))]:shadow-1xs', */
|
||||
|
||||
'dark:border-light-2/[0.06]',
|
||||
'dark:from-dark-2',
|
||||
'dark:to-dark-2',
|
||||
'dark:shadow-none',
|
||||
|
||||
'group open:dark:to-dark-2/8',
|
||||
'group open:to-light-1/6',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { DocumentBlockFile } from '@gitbook/api';
|
||||
|
||||
import { getSimplifiedContentType } from '@/lib/files';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Caption } from './Caption';
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
||||
export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
const { block, context } = props;
|
||||
|
||||
const contentRef = await context.resolveContentRef(block.data.ref);
|
||||
const file = contentRef?.file;
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = getSimplifiedContentType(file.contentType);
|
||||
|
||||
return (
|
||||
<Caption {...props} wrapperStyle={[]}>
|
||||
<a
|
||||
href={file.downloadURL}
|
||||
download={file.name}
|
||||
className={tcls(
|
||||
'group/file',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'border',
|
||||
'px-5',
|
||||
'py-3',
|
||||
'border-dark/3',
|
||||
'rounded-lg',
|
||||
'hover:text-primary-600',
|
||||
'dark:border-light/3',
|
||||
'dark:hover:text-primary-300',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'min-w-14',
|
||||
'mr-5',
|
||||
'pr-5',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
'border-r',
|
||||
'border-dark/2',
|
||||
'dark:border-light/2',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<FileIcon
|
||||
contentType={contentType}
|
||||
className={tcls('size-5', 'text-primary')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'text-dark-4/8',
|
||||
'group-hover/file:text-dark',
|
||||
'dark:text-light-4/7',
|
||||
'dark:group-hover/file:text-light',
|
||||
)}
|
||||
>
|
||||
{getHumanFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={tcls('text-base')}>{file.name}</div>
|
||||
<div className={tcls('text-sm', 'opacity-9', 'dark:opacity-8')}>
|
||||
{contentType}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Caption>
|
||||
);
|
||||
}
|
||||
|
||||
const ONE_KB = 1024;
|
||||
const ONE_MB = ONE_KB * 1024;
|
||||
|
||||
/**
|
||||
* Return a file size as human readable formatted string.
|
||||
*/
|
||||
function getHumanFileSize(size: number): string {
|
||||
if (size > ONE_MB) {
|
||||
const mbSize = size / ONE_MB;
|
||||
return `${mbSize.toFixed(0)}MB`;
|
||||
}
|
||||
if (size > ONE_KB) {
|
||||
const kbSize = size / ONE_KB;
|
||||
return `${kbSize.toFixed(0)}KB`;
|
||||
}
|
||||
|
||||
return `${size}B`;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { SimplifiedFileType } from '@/lib/files';
|
||||
|
||||
/**
|
||||
* Render an appropriate icon for a file.
|
||||
*/
|
||||
export function FileIcon(props: { contentType: SimplifiedFileType | null; className: string }) {
|
||||
const { contentType, className } = props;
|
||||
|
||||
switch (contentType) {
|
||||
case 'pdf':
|
||||
return <Icon icon="file-pdf" className={className} />;
|
||||
case 'image':
|
||||
return <Icon icon="file-image" className={className} />;
|
||||
case 'archive':
|
||||
return <Icon icon="file-archive" className={className} />;
|
||||
default:
|
||||
return <Icon icon="file-download" className={className} />;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { DocumentBlockHint } from '@gitbook/api';
|
||||
import { Icon, IconName } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
|
||||
export function Hint(props: BlockProps<DocumentBlockHint>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
const hintStyle = HINT_STYLES[block.data.style] ?? HINT_STYLES.info;
|
||||
const firstLine = getBlockTextStyle(block.nodes[0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'hint',
|
||||
'p-4',
|
||||
'transition-colors',
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-none',
|
||||
hintStyle.style,
|
||||
style,
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-row')}>
|
||||
<Icon
|
||||
icon={hintStyle.icon}
|
||||
className={tcls(
|
||||
'size-5',
|
||||
'mr-4',
|
||||
'mt-0.5',
|
||||
firstLine.lineHeight,
|
||||
hintStyle.iconColor,
|
||||
)}
|
||||
/>
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
nodes={block.nodes}
|
||||
blockStyle={tcls(
|
||||
hintStyle.bodyColor,
|
||||
// render hash icon on the other side of the heading
|
||||
'flip-heading-hash',
|
||||
)}
|
||||
style={['flex-1', 'space-y-4', '[&_.hint]:border', '[&_pre]:border']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HINT_STYLES: {
|
||||
[style in DocumentBlockHint['data']['style']]: {
|
||||
icon: IconName;
|
||||
iconColor: ClassValue;
|
||||
bodyColor: ClassValue;
|
||||
style: ClassValue;
|
||||
};
|
||||
} = {
|
||||
info: {
|
||||
icon: 'circle-info',
|
||||
iconColor: ['text-primary-500', 'dark:text-primary-400'],
|
||||
bodyColor: [
|
||||
'[&_a]:text-primary-500',
|
||||
'[&_a:hover]:text-primary-600',
|
||||
'dark:[&_a]:text-primary-400',
|
||||
'dark:[&_a:hover]:text-primary-300',
|
||||
],
|
||||
style: [
|
||||
'bg-dark-1/1',
|
||||
'border-dark/3',
|
||||
'dark:bg-light/1',
|
||||
'dark:border-light/3',
|
||||
'[&_.can-override-bg]:bg-dark-1/2',
|
||||
'[&_.can-override-text]:text-dark',
|
||||
'dark:[&_.can-override-bg]:bg-light/2',
|
||||
'dark:[&_.can-override-text]:text-light',
|
||||
],
|
||||
},
|
||||
warning: {
|
||||
icon: 'circle-exclamation',
|
||||
iconColor: ['text-amber-500', 'dark:text-orange-400'], // Darker shades of orange-* mismatch with lighter shades, so in light mode we use amber text on top of orange bg.
|
||||
bodyColor: [
|
||||
'[&_a]:text-orange-800',
|
||||
'[&_a:hover]:text-orange-900',
|
||||
'dark:[&_a]:text-orange-400',
|
||||
'dark:[&_a:hover]:text-orange-300',
|
||||
'[&_.can-override-bg]:bg-orange-500/3',
|
||||
'[&_.can-override-text]:text-orange-800',
|
||||
'dark:[&_.can-override-text]:text-orange-400',
|
||||
'decoration-orange-800/6',
|
||||
'dark:decoration-orange-400/6',
|
||||
],
|
||||
style: ['bg-orange-500/2', 'border-orange-500/4'],
|
||||
},
|
||||
danger: {
|
||||
icon: 'triangle-exclamation',
|
||||
iconColor: ['text-red-500', 'dark:text-red-400'],
|
||||
bodyColor: [
|
||||
'[&_a]:text-red-800',
|
||||
'[&_a:hover]:text-red-900',
|
||||
'dark:[&_a]:text-red-400',
|
||||
'dark:[&_a:hover]:text-red-300',
|
||||
'[&_.can-override-bg]:bg-red-500/3',
|
||||
'[&_.can-override-text]:text-red-400',
|
||||
'decoration-red-800/6',
|
||||
'dark:decoration-red-400/6',
|
||||
],
|
||||
style: ['bg-red-500/2', 'border-red-500/4'],
|
||||
},
|
||||
success: {
|
||||
icon: 'circle-check',
|
||||
iconColor: ['text-green-500', 'dark:text-green-400'],
|
||||
bodyColor: [
|
||||
'[&_a]:text-green-800',
|
||||
'[&_a:hover]:text-green-900',
|
||||
'dark:[&_a]:text-green-400',
|
||||
'dark:[&_a:hover]:text-green-300',
|
||||
'[&_.can-override-bg]:bg-green-500/3',
|
||||
'[&_.can-override-text]:text-green-800',
|
||||
'dark:[&_.can-override-text]:text-green-400',
|
||||
'decoration-green-800/6',
|
||||
'dark:decoration-green-400/6',
|
||||
],
|
||||
style: ['bg-green-500/2', 'border-green-500/4'],
|
||||
},
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import {
|
||||
DocumentBlockListOrdered,
|
||||
DocumentBlockListTasks,
|
||||
DocumentBlockListUnordered,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function List(
|
||||
props: BlockProps<
|
||||
DocumentBlockListUnordered | DocumentBlockListOrdered | DocumentBlockListTasks
|
||||
>,
|
||||
) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
return (
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
tag={getListTag(block.type)}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
style={['space-y-2', style]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getListTag(
|
||||
type:
|
||||
| DocumentBlockListUnordered['type']
|
||||
| DocumentBlockListOrdered['type']
|
||||
| DocumentBlockListTasks['type'],
|
||||
) {
|
||||
switch (type) {
|
||||
case 'list-ordered':
|
||||
return 'ol';
|
||||
case 'list-unordered':
|
||||
case 'list-tasks':
|
||||
return 'ul';
|
||||
default:
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import {
|
||||
DocumentBlock,
|
||||
DocumentBlockListItem,
|
||||
DocumentBlockListOrdered,
|
||||
DocumentBlockListUnordered,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { Checkbox } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
import { getBlockTextStyle } from './spacing';
|
||||
|
||||
export function ListItem(props: BlockProps<DocumentBlockListItem>) {
|
||||
const { block, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
const parent = ancestorBlocks[ancestorBlocks.length - 1];
|
||||
assert(
|
||||
(parent && parent.type === 'list-ordered') ||
|
||||
parent.type === 'list-unordered' ||
|
||||
parent.type === 'list-tasks',
|
||||
'Invalid parent list type',
|
||||
);
|
||||
|
||||
const blocksElement = (
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
blockStyle={tcls(
|
||||
'min-h-[1lh]',
|
||||
// flip heading hash icon if list item is a heading
|
||||
'flip-heading-hash',
|
||||
// remove margin-top for the first heading in a list
|
||||
'[&:is(h2)>div]:mt-0',
|
||||
'[&:is(h3)>div]:mt-0',
|
||||
'[&:is(h4)>div]:mt-0',
|
||||
)}
|
||||
style="space-y-2 flex flex-col flex-1"
|
||||
/>
|
||||
);
|
||||
|
||||
switch (parent.type) {
|
||||
case 'list-tasks':
|
||||
return (
|
||||
<ListItemLI block={block}>
|
||||
<ListItemPrefix block={block}>
|
||||
<Checkbox
|
||||
id={block.key!}
|
||||
disabled
|
||||
checked={block.data?.checked}
|
||||
className="relative"
|
||||
size="small"
|
||||
/>
|
||||
</ListItemPrefix>
|
||||
|
||||
<label htmlFor={block.key} className={tcls('flex-1')}>
|
||||
{blocksElement}
|
||||
</label>
|
||||
</ListItemLI>
|
||||
);
|
||||
case 'list-ordered':
|
||||
return (
|
||||
<ListItemLI block={block}>
|
||||
<ListItemPrefix block={block}>
|
||||
<PseudoBefore
|
||||
content={getOrderedListItemPrefixContent({
|
||||
depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
|
||||
block,
|
||||
parent,
|
||||
})}
|
||||
style={{
|
||||
fontSize: 'min(1em, 24px)',
|
||||
}}
|
||||
/>
|
||||
</ListItemPrefix>
|
||||
{blocksElement}
|
||||
</ListItemLI>
|
||||
);
|
||||
case 'list-unordered':
|
||||
return (
|
||||
<ListItemLI block={block}>
|
||||
<ListItemPrefix block={block}>
|
||||
<PseudoBefore
|
||||
content={getUnorderedListItemsPrefixContent({
|
||||
depth: getListItemDepth({ ancestorBlocks, type: parent.type }),
|
||||
})}
|
||||
fontFamily="Arial"
|
||||
style={{ fontSize: 'min(1.5em, 24px)', lineHeight: 1 }}
|
||||
/>
|
||||
</ListItemPrefix>
|
||||
{blocksElement}
|
||||
</ListItemLI>
|
||||
);
|
||||
default:
|
||||
assertNever(parent);
|
||||
}
|
||||
}
|
||||
|
||||
function getListItemDepth(input: {
|
||||
ancestorBlocks: DocumentBlock[];
|
||||
type: DocumentBlockListOrdered['type'] | DocumentBlockListUnordered['type'];
|
||||
}): number {
|
||||
const { ancestorBlocks, type } = input;
|
||||
|
||||
let depth = -1;
|
||||
|
||||
for (let i = ancestorBlocks.length - 1; i >= 0; i--) {
|
||||
const block = ancestorBlocks[i];
|
||||
if (block.type === type) {
|
||||
depth = depth + 1;
|
||||
continue;
|
||||
}
|
||||
if (block.type === 'list-item') {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return depth;
|
||||
}
|
||||
|
||||
function ListItemLI(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
|
||||
const textStyle = getBlockTextStyle(props.block);
|
||||
return <li className={tcls(textStyle.lineHeight, 'flex items-start')}>{props.children}</li>;
|
||||
}
|
||||
|
||||
function ListItemPrefix(props: { block: DocumentBlockListItem; children: React.ReactNode }) {
|
||||
const textStyle = getBlockTextStyle(props.block);
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
textStyle.textSize,
|
||||
textStyle.lineHeight,
|
||||
'flex items-center justify-center mr-1 min-h-[1lh] min-w-6 text-dark/6 dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getUnorderedListItemsPrefixContent(input: { depth: number }): string {
|
||||
switch (input.depth % 3) {
|
||||
case 0:
|
||||
return '•';
|
||||
case 1:
|
||||
return '◦';
|
||||
case 2:
|
||||
return '▪';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
function PseudoBefore(props: {
|
||||
style?: React.CSSProperties;
|
||||
content: string;
|
||||
fontFamily?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="before:font-var before:content-[--pseudoBefore--content]"
|
||||
style={
|
||||
{
|
||||
'--pseudoBefore--content': `'${props.content}'`,
|
||||
'--font-family': props.fontFamily ?? 'inherit',
|
||||
...props.style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getOrderedListItemPrefixContent(input: {
|
||||
depth: number;
|
||||
parent: DocumentBlockListOrdered;
|
||||
block: DocumentBlockListItem;
|
||||
}): string {
|
||||
const { parent, block } = input;
|
||||
const start = parent.data.start ?? 1;
|
||||
const index = parent.nodes.findIndex((node) => node.key === block.key) ?? 0;
|
||||
const value = index + start;
|
||||
switch (input.depth % 3) {
|
||||
// Use numbers
|
||||
case 0: {
|
||||
return `${value}.`;
|
||||
}
|
||||
// Use letters
|
||||
case 1: {
|
||||
const letters = 'abcdefghijklmnopqrstuvwxyz';
|
||||
return `${letters[(value - 1) % letters.length]}.`;
|
||||
}
|
||||
// Use roman numbers
|
||||
case 2: {
|
||||
return `${toRoman(value).toLowerCase()}.`;
|
||||
}
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
function toRoman(input: number): string {
|
||||
const lookup = {
|
||||
M: 1000,
|
||||
CM: 900,
|
||||
D: 500,
|
||||
CD: 400,
|
||||
C: 100,
|
||||
XC: 90,
|
||||
L: 50,
|
||||
XL: 40,
|
||||
X: 10,
|
||||
IX: 9,
|
||||
V: 5,
|
||||
IV: 4,
|
||||
I: 1,
|
||||
};
|
||||
let roman = '';
|
||||
let number = input;
|
||||
for (const i in lookup) {
|
||||
while (number >= lookup[i as keyof typeof lookup]) {
|
||||
roman += i;
|
||||
number -= lookup[i as keyof typeof lookup];
|
||||
}
|
||||
}
|
||||
return roman;
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
@import '@scalar/api-client-react/style.css';
|
||||
|
||||
.light .scalar-modal-layout,
|
||||
.light .scalar-app,
|
||||
.light .scalar {
|
||||
--scalar-color-1: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--tint-color-300, 180 180 180)),
|
||||
rgb(var(--dark-base, 23 23 23)) 96%
|
||||
);
|
||||
--scalar-color-2: color-mix(
|
||||
in srgb,
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.72)
|
||||
);
|
||||
--scalar-color-3: color-mix(
|
||||
in srgb,
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.4)
|
||||
);
|
||||
--scalar-color-accent: #007d9c;
|
||||
|
||||
--scalar-background-1: rgb(var(--light-base, 255 255 255));
|
||||
--scalar-background-2: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--tint-color-800, 30 30 30)),
|
||||
var(--scalar-background-1) 96%
|
||||
);
|
||||
--scalar-background-3: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--tint-color-800, 30 30 30)),
|
||||
var(--scalar-background-1) 90%
|
||||
);
|
||||
--scalar-background-accent: #007d9c1f;
|
||||
--scalar-code-language-color-supersede: var(--scalar-color-1);
|
||||
--scalar-code-languages-background-supersede: var(--scalar-background-1);
|
||||
--scalar-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.08)
|
||||
);
|
||||
|
||||
--scalar-color-green: #0a6355;
|
||||
--scalar-color-red: #dc1b19;
|
||||
--scalar-color-yellow: #ffc90d;
|
||||
--scalar-color-blue: rgb(var(--primary-color-500, 52 109 219));
|
||||
--scalar-color-orange: #ff8d4d;
|
||||
--scalar-color-purple: #8250df;
|
||||
|
||||
--scalar-scrollbar-color: rgba(255, 255, 255, 0.24);
|
||||
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.48);
|
||||
|
||||
--scalar-button-1: rgb(49 53 56);
|
||||
--scalar-button-1-color: #fff;
|
||||
--scalar-button-1-hover: rgb(28 31 33);
|
||||
|
||||
--scalar-shadow-1: 0 1px 3px 0 rgba(0, 0, 0, 0.11);
|
||||
--scalar-shadow-2: rgba(0, 0, 0, 0.08) 0px 13px 20px 0px, rgba(0, 0, 0, 0.08) 0px 3px 8px 0px,
|
||||
#eeeeed 0px 0 0 1px;
|
||||
|
||||
--scalar-selection-background: rgba(96, 175, 255, 0.4);
|
||||
--scalar-selection-color: rgb(var(--dark-base, 22 22 22));
|
||||
}
|
||||
.dark .scalar-modal-layout,
|
||||
.dark .scalar-app,
|
||||
.dark .scalar {
|
||||
--scalar-color-1: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--tint-color-700, 70 70 70)),
|
||||
rgb(var(--light-base, 255 255 255)) 100%
|
||||
);
|
||||
--scalar-color-2: color-mix(
|
||||
in srgb,
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.64)
|
||||
);
|
||||
--scalar-color-3: color-mix(
|
||||
in srgb,
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.4)
|
||||
);
|
||||
--scalar-color-accent: #50b7e0;
|
||||
|
||||
--scalar-background-1: rgb(var(--dark-base, 22 22 22));
|
||||
--scalar-background-2: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--tint-color-200, 200 200 200)),
|
||||
var(--scalar-background-1) 92%
|
||||
);
|
||||
--scalar-background-3: color-mix(
|
||||
in srgb,
|
||||
rgb(var(--tint-color-200, 200 200 200)),
|
||||
var(--scalar-background-1) 88%
|
||||
);
|
||||
--scalar-background-accent: #8ab4f81f;
|
||||
--scalar-code-languages-background-supersede: var(--scalar-background-1);
|
||||
--scalar-border-color: color-mix(
|
||||
in srgb,
|
||||
var(--scalar-color-1),
|
||||
transparent calc(100% - 100% * 0.08)
|
||||
);
|
||||
|
||||
--scalar-color-green: #56b6c2;
|
||||
--scalar-color-red: rgb(245 124 97);
|
||||
--scalar-color-yellow: #edbe20;
|
||||
--scalar-color-blue: rgb(var(--primary-color-400, 93 138 226));
|
||||
--scalar-color-orange: #d19a66;
|
||||
--scalar-color-purple: #5203d1;
|
||||
|
||||
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
|
||||
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
|
||||
|
||||
--scalar-button-1: #f6f6f6;
|
||||
--scalar-button-1-color: #000;
|
||||
--scalar-button-1-hover: #e7e7e7;
|
||||
|
||||
--scalar-shadow-1: 0 1px 3px 0 rgb(0, 0, 0, 0.1);
|
||||
--scalar-shadow-2: rgba(15, 15, 15, 0.2) 0px 3px 6px, rgba(15, 15, 15, 0.4) 0px 9px 24px,
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
|
||||
--scalar-selection-background: rgba(96, 175, 255, 0.4);
|
||||
--scalar-selection-color: rgb(var(--light-base, 255 255 255));
|
||||
}
|
||||
.scalar-modal-layout,
|
||||
.scalar-app,
|
||||
.scalar {
|
||||
--scalar-font: initial;
|
||||
--scalar-font-code: var(--font-mono);
|
||||
|
||||
--scalar-paragraph: 16px;
|
||||
--scalar-small: 14px;
|
||||
--scalar-mini: 13px;
|
||||
--scalar-micro: 12px;
|
||||
|
||||
--scalar-bold: 600;
|
||||
--scalar-semibold: 500;
|
||||
--scalar-regular: 400;
|
||||
|
||||
/* Font sizes for interactive applications (not rendered text content) */
|
||||
--scalar-font-size-1: 24px;
|
||||
--scalar-font-size-2: 16px;
|
||||
--scalar-font-size-3: 14px;
|
||||
--scalar-font-size-4: 13px;
|
||||
--scalar-font-size-5: 12px;
|
||||
|
||||
--scalar-line-height-1: 32px;
|
||||
--scalar-line-height-2: 24px;
|
||||
--scalar-line-height-3: 20px;
|
||||
--scalar-line-height-4: 18px;
|
||||
--scalar-line-height-5: 16px;
|
||||
|
||||
--scalar-app-header-height: 35px;
|
||||
}
|
||||
.scalar input::placeholder {
|
||||
color: var(--scalar-color-3);
|
||||
}
|
||||
.scalar .scalar-app-header {
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
padding: 6px 12px 6px 12px;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
font-size: 14px;
|
||||
height: var(--scalar-app-header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
.scalar .scalar-api-client {
|
||||
max-height: calc(100dvh - (100px + var(--scalar-app-header-height))) !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.scalar-api-client__close {
|
||||
appearance: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
color: var(--scalar-color-1);
|
||||
font-size: var(--scalar-small);
|
||||
font-weight: var(--scalar-semibold);
|
||||
}
|
||||
.scalar-api-client__close:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.scalar .scalar-app-layout {
|
||||
background: var(--scalar-background-3);
|
||||
height: calc(100dvh - 100px);
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
animation: scalarapiclientfadein 0.35s forwards;
|
||||
z-index: 1002;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@keyframes scalarapiclientfadein {
|
||||
from {
|
||||
transform: translate3d(0, 20px, 0) scale(0.985);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.scalar .scalar-app-exit {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
transition: all 0.3s ease-in-out;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
animation: scalardrawerexitfadein 0.35s forwards;
|
||||
}
|
||||
@keyframes scalardrawerexitfadein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.scalar-container {
|
||||
overflow: hidden;
|
||||
visibility: visible;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scalar .url-form-input {
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.scalar .scalar-container {
|
||||
line-height: normal;
|
||||
}
|
||||
.scalar .scalar-app-header span {
|
||||
color: var(--scalar-color-3);
|
||||
}
|
||||
.scalar .scalar-app-header a {
|
||||
color: var(--scalar-color-1);
|
||||
}
|
||||
.scalar .scalar-app-header a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.scalar-activate {
|
||||
width: fit-content;
|
||||
margin: 0px 0.75rem 0.75rem auto;
|
||||
line-height: 24px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.scalar-activate-button {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--scalar-color-blue);
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.scalar-activate-button {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.scalar-activate:hover .scalar-activate-button {
|
||||
background: var(--scalar-background-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scalar-app-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.scalar .request-method {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Use :where to lower specificity to 0 */
|
||||
.scalar .custom-scroll {
|
||||
overflow-y: auto;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.scalar .custom-scroll:hover {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.24) transparent;
|
||||
}
|
||||
.dark .scalar .custom-scroll:hover {
|
||||
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
|
||||
}
|
||||
.scalar .custom-scroll:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--scalar-scrollbar-color, var(--default-theme-scrollbar-color));
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
.scalar .custom-scroll::-webkit-scrollbar-thumb:active {
|
||||
background: var(--scalar-scrollbar-color-active, var(--default-theme-scrollbar-color-active));
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
.scalar .custom-scroll::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
.scalar .custom-scroll::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
.scalar .custom-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scalar .custom-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.scalar .custom-scroll {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
.dark .scalar .client-wrapper-bg-color {
|
||||
background: linear-gradient(
|
||||
color-mix(in srgb, var(--tw-bg-base) 6%, transparent) 1%,
|
||||
color-mix(in srgb, var(--scalar-background-1) 30%, black) 9%
|
||||
);
|
||||
}
|
||||
.light .scalar .client-wrapper-bg-color {
|
||||
background-color: var(--scalar-background-2) !important;
|
||||
}
|
||||
.scalar .gitbook-show {
|
||||
display: block !important;
|
||||
}
|
||||
.scalar .gitbook-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { DocumentBlockReusableContent } from '@gitbook/api';
|
||||
|
||||
import { getDocument } from '@/lib/api';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { UnwrappedBlocks } from './Blocks';
|
||||
|
||||
export async function ReusableContent(props: BlockProps<DocumentBlockReusableContent>) {
|
||||
const { block, context, ancestorBlocks } = props;
|
||||
|
||||
if (!context.content) {
|
||||
throw new Error(`Expected a content context to render a reusable content block`);
|
||||
}
|
||||
|
||||
const resolved = await context.resolveContentRef(block.data.ref);
|
||||
if (!resolved?.reusableContent?.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = await getDocument(context.content.spaceId, resolved.reusableContent.document);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnwrappedBlocks
|
||||
nodes={document.nodes}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
context={context}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { DocumentBlockStepper } from '@gitbook/api';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function Stepper(props: BlockProps<DocumentBlockStepper>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
return (
|
||||
<Blocks
|
||||
blockStyle={style}
|
||||
{...contextProps}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { DocumentBlockStepperStep } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
|
||||
export function StepperStep(props: BlockProps<DocumentBlockStepperStep>) {
|
||||
const { block, style, ancestorBlocks, ...contextProps } = props;
|
||||
|
||||
const ancestor = ancestorBlocks[ancestorBlocks.length - 1];
|
||||
assert(ancestor.type === 'stepper', 'Ancestor block must be a stepper');
|
||||
|
||||
const index = ancestor.nodes.indexOf(block);
|
||||
|
||||
const firstChild = block.nodes[0];
|
||||
const marginAdjustClassName = (() => {
|
||||
if (!firstChild) {
|
||||
return '';
|
||||
}
|
||||
switch (firstChild.type) {
|
||||
case 'heading-1':
|
||||
return '-mt-9';
|
||||
case 'heading-2':
|
||||
return '-mt-[calc(1.25rem+1px)]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={tcls('flex flex-row gap-4 md:gap-8 max-w-3xl w-full mx-auto', style)}>
|
||||
<div className="relative select-none">
|
||||
<div
|
||||
className={tcls(
|
||||
'can-override-bg can-override-text flex size-[calc(1.75rem+1px)] items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900 tabular-nums',
|
||||
'font-medium text-primary-800 dark:text-primary-200',
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-[0.875rem] top-9 w-px bg-primary-50 dark:bg-primary-900 can-override-bg" />
|
||||
</div>
|
||||
<Blocks
|
||||
{...contextProps}
|
||||
nodes={block.nodes}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
style={['flex-1 pb-6 [&>*+*]:mt-5', marginAdjustClassName]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { DocumentTableViewGrid } from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { RecordColumnValue } from './RecordColumnValue';
|
||||
import { TableRecordKV, TableViewProps } from './Table';
|
||||
import styles from './table.module.css';
|
||||
import { getColumnWidth } from './ViewGrid';
|
||||
|
||||
export function RecordRow(
|
||||
props: TableViewProps<DocumentTableViewGrid> & {
|
||||
record: TableRecordKV;
|
||||
autoSizedColumns: string[];
|
||||
fixedColumns: string[];
|
||||
},
|
||||
) {
|
||||
const { view, autoSizedColumns, fixedColumns } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.row} role="row">
|
||||
{view.columns.map((column) => {
|
||||
const columnWidth = getColumnWidth({
|
||||
column,
|
||||
columnWidths: view.columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={column}
|
||||
role="cell"
|
||||
className={styles.cell}
|
||||
style={{
|
||||
width: columnWidth,
|
||||
minWidth: columnWidth || '100px',
|
||||
}}
|
||||
>
|
||||
<RecordColumnValue {...props} column={column} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { DocumentTableViewGrid } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { RecordRow } from './RecordRow';
|
||||
import { TableViewProps } from './Table';
|
||||
import styles from './table.module.css';
|
||||
import { getColumnAlignment } from './utils';
|
||||
|
||||
/* Columns are sized in 3 ways:
|
||||
1. Set to auto-size by default, these columns share the available width
|
||||
2. Explicitly set by the user by dragging column separator (we then turn off auto-size)
|
||||
3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px
|
||||
*/
|
||||
export function ViewGrid(props: TableViewProps<DocumentTableViewGrid>) {
|
||||
const { block, view, records, style } = props;
|
||||
|
||||
/* Calculate how many columns are auto-sized vs fixed width */
|
||||
const columnWidths = view.columnWidths;
|
||||
const autoSizedColumns = view.columns.filter((column) => !columnWidths?.[column]);
|
||||
const fixedColumns = view.columns.filter((column) => columnWidths?.[column]);
|
||||
|
||||
const tableWidth = autoSizedColumns.length > 0 ? 'w-full' : 'w-fit';
|
||||
|
||||
/* Only show the header when configured and not empty */
|
||||
const withHeader =
|
||||
!view.hideHeader &&
|
||||
view.columns.some((columnId) => block.data.definition[columnId].title.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className={tcls(style, styles.tableWrapper)}>
|
||||
{/* Table */}
|
||||
<div role="table" className={tcls('flex', 'flex-col')}>
|
||||
{/* Header */}
|
||||
{withHeader && (
|
||||
<div
|
||||
role="rowgroup"
|
||||
className={tcls(
|
||||
tableWidth,
|
||||
styles.rowGroup,
|
||||
'straight-corners:rounded-none',
|
||||
)}
|
||||
>
|
||||
<div role="row" className={tcls('flex', 'w-full')}>
|
||||
{view.columns.map((column) => {
|
||||
const alignment = getColumnAlignment(block.data.definition[column]);
|
||||
return (
|
||||
<div
|
||||
key={column}
|
||||
role="columnheader"
|
||||
className={tcls(
|
||||
styles.columnHeader,
|
||||
alignment === 'right' ? 'text-right' : null,
|
||||
alignment === 'center' ? 'text-center' : null,
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth({
|
||||
column,
|
||||
columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
}),
|
||||
minWidth: columnWidths?.[column] || '100px',
|
||||
}}
|
||||
title={block.data.definition[column].title}
|
||||
>
|
||||
{block.data.definition[column].title}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
role="rowgroup"
|
||||
className={tcls('flex', 'flex-col', tableWidth, '[&>*+*]:border-t')}
|
||||
>
|
||||
{records.map((record) => (
|
||||
<RecordRow
|
||||
key={record[0]}
|
||||
record={record}
|
||||
autoSizedColumns={autoSizedColumns}
|
||||
fixedColumns={fixedColumns}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getColumnWidth = ({
|
||||
column,
|
||||
columnWidths,
|
||||
autoSizedColumns,
|
||||
fixedColumns,
|
||||
}: {
|
||||
column: string;
|
||||
columnWidths: Record<string, number> | undefined;
|
||||
autoSizedColumns: string[];
|
||||
fixedColumns: string[];
|
||||
}) => {
|
||||
const columnWidth = columnWidths?.[column];
|
||||
|
||||
/* Column was explicitly set by user or user turned off auto-sizing (in that case, columnWidth should've also been set to 100px) */
|
||||
if (columnWidth) return `${columnWidth}px`;
|
||||
|
||||
/* Fallback minimum width for columns, so the columns don't become unreadable from being too narrow and instead table will become scrollable. */
|
||||
const minAutoColumnWidth = '100px';
|
||||
|
||||
const totalFixedWidth = fixedColumns.reduce((sum, col) => {
|
||||
return sum + (columnWidths?.[col] || 0);
|
||||
}, 0);
|
||||
|
||||
/* Column should use auto-sizing, which means it grows to fill available space */
|
||||
const availableWidth = `calc((100% - ${totalFixedWidth}px) / ${autoSizedColumns.length})`;
|
||||
return `clamp(${minAutoColumnWidth}, ${availableWidth}, 100%)`;
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/* Detect whether a scrollbar exists on the table */
|
||||
@keyframes detect-scroll {
|
||||
from,
|
||||
to {
|
||||
--can-scroll: ;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply styles to the Table if scrollbar exists */
|
||||
.tableWrapper {
|
||||
animation: detect-scroll linear;
|
||||
animation-timeline: scroll(self x);
|
||||
|
||||
--border-radius-if-can-scroll: var(--can-scroll) 0.375rem;
|
||||
--border-radius-if-cant-scroll: 0;
|
||||
border-radius: var(--border-radius-if-can-scroll, var(--border-radius-if-cant-scroll));
|
||||
|
||||
--border-width-if-can-scroll: var(--can-scroll) 1px;
|
||||
--border-width-if-cant-scroll: 0;
|
||||
border-width: var(--border-width-if-can-scroll, var(--border-width-if-cant-scroll));
|
||||
|
||||
@apply relative grid w-full overflow-x-auto overflow-y-hidden mx-auto border-dark/3;
|
||||
}
|
||||
|
||||
:global(.dark) .tableWrapper {
|
||||
@apply border-tint-50;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
@apply text-sm font-medium py-3 px-4 text-tint-900;
|
||||
}
|
||||
|
||||
:global(.dark) .columnHeader {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.row {
|
||||
@apply flex border-tint-700/2;
|
||||
}
|
||||
|
||||
:global(.dark) .row {
|
||||
@apply border-tint-300/3;
|
||||
}
|
||||
|
||||
.rowGroup {
|
||||
@apply flex flex-col border rounded-lg bg-tint-800/1 border-tint-700/2;
|
||||
}
|
||||
|
||||
:global(.dark) .rowGroup {
|
||||
@apply bg-tint-300/2 border-tint-300/3;
|
||||
}
|
||||
|
||||
.cell {
|
||||
@apply flex-1 align-middle border-dark/2 py-2 px-4 text-sm;
|
||||
}
|
||||
|
||||
:global(.dark) .cell {
|
||||
@apply border-light/2;
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { useHash, useIsMounted } from '@/components/hooks';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
// How many titles are remembered:
|
||||
const TITLES_MAX = 5;
|
||||
|
||||
export interface TabsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925
|
||||
type SelectorMapper<Type> = {
|
||||
[Property in keyof Type]: Type[Property];
|
||||
};
|
||||
type TabsInput = {
|
||||
id: string;
|
||||
tabs: SelectorMapper<TabsItem>[];
|
||||
};
|
||||
|
||||
interface TabsState {
|
||||
activeIds: {
|
||||
[tabsBlockId: string]: string;
|
||||
};
|
||||
activeTitles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client side component for the tabs, taking care of interactions.
|
||||
*/
|
||||
export function DynamicTabs(
|
||||
props: TabsInput & {
|
||||
tabsBody: React.ReactNode[];
|
||||
style: ClassValue;
|
||||
},
|
||||
) {
|
||||
const { id, tabs, tabsBody, style } = props;
|
||||
|
||||
const hash = useHash();
|
||||
|
||||
const activeState = useRecoilValue(tabsActiveSelector({ id, tabs }));
|
||||
|
||||
// To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage),
|
||||
// once the component has been mounted.
|
||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||
// and get stuck into an inconsistent state.
|
||||
const mounted = useIsMounted();
|
||||
const active = mounted ? activeState : tabs[0];
|
||||
|
||||
const setTabsState = useSetRecoilState(tabsAtom);
|
||||
|
||||
/**
|
||||
* When clicking to select a tab, we:
|
||||
* - mark this specific ID as selected
|
||||
* - store the ID to auto-select other tabs with the same title
|
||||
*/
|
||||
const onSelectTab = React.useCallback(
|
||||
(tab: TabsItem) => {
|
||||
setTabsState((prev) => ({
|
||||
activeIds: {
|
||||
...prev.activeIds,
|
||||
[id]: tab.id,
|
||||
},
|
||||
activeTitles: tab.title
|
||||
? prev.activeTitles
|
||||
.filter((t) => t !== tab.title)
|
||||
.concat([tab.title])
|
||||
.slice(-TITLES_MAX)
|
||||
: prev.activeTitles,
|
||||
}));
|
||||
},
|
||||
[id, setTabsState],
|
||||
);
|
||||
|
||||
/**
|
||||
* When the hash changes, we try to select the tab containing the targetted element.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabAncestor = activeElement.closest('[role="tabpanel"]');
|
||||
if (!tabAncestor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectTab(tab);
|
||||
}, [hash, tabs, onSelectTab]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
'ring-1',
|
||||
'ring-inset',
|
||||
'ring-dark/3',
|
||||
'flex',
|
||||
'overflow-hidden',
|
||||
'flex-col',
|
||||
'dark:ring-light/2',
|
||||
style,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
className={tcls(
|
||||
'group/tabs',
|
||||
'inline-flex',
|
||||
'flex-row',
|
||||
'self-stretch',
|
||||
'after:flex-[1]',
|
||||
'after:bg-dark-2/1',
|
||||
// if last tab is selected, apply rounded to :after element
|
||||
'[&:has(button.active-tab:last-of-type):after]:rounded-bl-md',
|
||||
'dark:after:bg-dark-1/5',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={active.id === tab.id}
|
||||
aria-controls={getTabPanelId(tab.id)}
|
||||
id={getTabButtonId(tab.id)}
|
||||
onClick={() => {
|
||||
onSelectTab(tab);
|
||||
}}
|
||||
className={tcls(
|
||||
//prev from active-tab
|
||||
'[&:has(+_.active-tab)]:rounded-br-md',
|
||||
|
||||
//next from active-tab
|
||||
'[.active-tab_+_&]:rounded-bl-md',
|
||||
|
||||
//next from active-tab
|
||||
'[.active-tab_+_:after]:rounded-br-md',
|
||||
|
||||
'inline-block',
|
||||
'text-sm',
|
||||
'px-3.5',
|
||||
'py-2',
|
||||
'transition-[color]',
|
||||
'font-[500]',
|
||||
'relative',
|
||||
|
||||
'after:transition-colors',
|
||||
'after:group-hover/tabs:border-transparent',
|
||||
'after:border-r',
|
||||
'after:absolute',
|
||||
'after:left-[unset]',
|
||||
'after:right-0',
|
||||
'after:border-dark/4',
|
||||
'after:top-[15%]',
|
||||
'after:h-[70%]',
|
||||
'after:w-[1px]',
|
||||
|
||||
'last:after:border-transparent',
|
||||
|
||||
'text-dark-2/7',
|
||||
'bg-dark-2/1',
|
||||
'dark:bg-dark-1/5',
|
||||
'hover:text-dark-2',
|
||||
|
||||
'dark:text-light-3/8',
|
||||
|
||||
'dark:after:border-light/2',
|
||||
'dark:hover:text-light-3',
|
||||
|
||||
'truncate',
|
||||
'max-w-full',
|
||||
|
||||
active.id === tab.id
|
||||
? [
|
||||
'shrink-0',
|
||||
'active-tab',
|
||||
'text-dark-2',
|
||||
'bg-transparent',
|
||||
'dark:text-light',
|
||||
'dark:bg-transparent',
|
||||
'after:[&.active-tab]:border-transparent',
|
||||
'after:[:has(+_&.active-tab)]:border-transparent',
|
||||
'after:[:has(&_+)]:border-transparent',
|
||||
]
|
||||
: null,
|
||||
)}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tabpanel"
|
||||
id={getTabPanelId(tab.id)}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className={tcls('p-4', tab.id !== active.id ? 'hidden' : null)}
|
||||
>
|
||||
{tabsBody[index]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsAtom = atom<TabsState>({
|
||||
key: 'tabsAtom',
|
||||
default: {
|
||||
activeIds: {},
|
||||
activeTitles: [],
|
||||
},
|
||||
effects: [
|
||||
// Persist the state to local storage
|
||||
({ trigger, setSelf, onSet }) => {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const localStorageKey = '@gitbook/tabsState';
|
||||
if (trigger === 'get') {
|
||||
const stored = localStorage.getItem(localStorageKey);
|
||||
if (stored) {
|
||||
setSelf(JSON.parse(stored));
|
||||
}
|
||||
}
|
||||
|
||||
onSet((newState) => {
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(newState));
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tabsActiveSelector = selectorFamily<TabsItem, SelectorMapper<TabsInput>>({
|
||||
key: 'tabsActiveSelector',
|
||||
get:
|
||||
(input) =>
|
||||
({ get }) => {
|
||||
const state = get(tabsAtom);
|
||||
return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the ID for a tab button.
|
||||
*/
|
||||
function getTabButtonId(tabId: string) {
|
||||
return `tab-${tabId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for a tab panel.
|
||||
* We use the ID of the tab itself as links can be pointing to this ID.
|
||||
*/
|
||||
function getTabPanelId(tabId: string) {
|
||||
return tabId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get explicitly selected tab in a set of tabs.
|
||||
*/
|
||||
function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null {
|
||||
const activeId = state.activeIds[input.id];
|
||||
return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best selected tab in a set of tabs by taking only title into account.
|
||||
*/
|
||||
function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null {
|
||||
return (
|
||||
input.tabs
|
||||
.map((item) => {
|
||||
return {
|
||||
item,
|
||||
score: state.activeTitles.indexOf(item.title),
|
||||
};
|
||||
})
|
||||
.filter(({ score }) => score >= 0)
|
||||
// .sortBy(({ score }) => -score)
|
||||
.sort(({ score: a }, { score: b }) => b - a)
|
||||
.map(({ item }) => item)[0] ?? null
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { DocumentBlockTabs } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { DynamicTabs, TabsItem } from './DynamicTabs';
|
||||
import { BlockProps } from '../Block';
|
||||
import { Blocks } from '../Blocks';
|
||||
|
||||
export function Tabs(props: BlockProps<DocumentBlockTabs>) {
|
||||
const { block, ancestorBlocks, document, style, context } = props;
|
||||
|
||||
const tabs: TabsItem[] = [];
|
||||
const tabsBody: React.ReactNode[] = [];
|
||||
|
||||
block.nodes.forEach((tab, index) => {
|
||||
tabs.push({
|
||||
id: tab.meta?.id ?? tab.key!,
|
||||
title: tab.data.title ?? '',
|
||||
});
|
||||
|
||||
tabsBody.push(
|
||||
<Blocks
|
||||
nodes={tab.nodes}
|
||||
document={document}
|
||||
ancestorBlocks={[...ancestorBlocks, block, tab]}
|
||||
context={context}
|
||||
blockStyle={tcls('flip-heading-hash')}
|
||||
style={tcls('w-full', 'space-y-4')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (context.mode === 'print') {
|
||||
// When printing, we display the tab, one after the other
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab, index) => (
|
||||
<DynamicTabs
|
||||
key={tab.id}
|
||||
id={block.key!}
|
||||
tabs={[tab]}
|
||||
tabsBody={[tabsBody[index]]}
|
||||
style={style}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <DynamicTabs id={block.key!} tabs={tabs} tabsBody={tabsBody} style={style} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './DocumentView';
|
||||
@@ -1,208 +0,0 @@
|
||||
import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api';
|
||||
import { CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import type { SectionsList } from '@/lib/api';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { HeaderLink } from './HeaderLink';
|
||||
import { HeaderLinkMore } from './HeaderLinkMore';
|
||||
import { HeaderLinks } from './HeaderLinks';
|
||||
import { HeaderLogo } from './HeaderLogo';
|
||||
import { SpacesDropdown } from './SpacesDropdown';
|
||||
import { SearchButton } from '../Search';
|
||||
import { SiteSectionTabs } from '../SiteSectionTabs';
|
||||
import { HeaderMobileMenu } from './HeaderMobileMenu';
|
||||
/**
|
||||
* Render the header for the space.
|
||||
*/
|
||||
export function Header(props: {
|
||||
space: Space;
|
||||
site: Site | null;
|
||||
spaces: Space[];
|
||||
sections: SectionsList | null;
|
||||
context: ContentRefContext;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
withTopHeader?: boolean;
|
||||
}) {
|
||||
const { context, space, site, spaces, sections, customization, withTopHeader } = props;
|
||||
const isCustomizationDefault =
|
||||
customization.header.preset === CustomizationHeaderPreset.Default;
|
||||
const hasSiteSections = sections && sections.list.length > 1;
|
||||
const isMultiVariants = site && spaces.length > 1;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
|
||||
'sticky',
|
||||
'top-0',
|
||||
'z-10',
|
||||
'w-full',
|
||||
'flex-none',
|
||||
'shadow-thinbottom',
|
||||
'dark:shadow-light/2',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
withTopHeader ? null : 'lg:hidden',
|
||||
'text-sm',
|
||||
'bg-opacity-9',
|
||||
'dark:bg-opacity-9',
|
||||
'backdrop-blur-lg',
|
||||
'contrast-more:bg-opacity-11',
|
||||
'contrast-more:dark:bg-opacity-11',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
!isCustomizationDefault &&
|
||||
withTopHeader && [
|
||||
'bg-header-background',
|
||||
'shadow-thinbottom',
|
||||
'dark:shadow-light/2',
|
||||
],
|
||||
)}
|
||||
>
|
||||
<div className={tcls('scroll-nojump')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
'lg:gap-8',
|
||||
'flex',
|
||||
'h-16',
|
||||
'items-center',
|
||||
'justify-between',
|
||||
'w-full',
|
||||
CONTAINER_STYLE,
|
||||
)}
|
||||
>
|
||||
<div className="flex max-w-full shrink min-w-0 gap-2 lg:gap-4 justify-start items-center">
|
||||
<HeaderMobileMenu
|
||||
className={tcls(
|
||||
'lg:hidden',
|
||||
'-ml-2',
|
||||
customization.header.preset ===
|
||||
CustomizationHeaderPreset.Default
|
||||
? ['text-dark', 'dark:text-light']
|
||||
: 'text-header-link',
|
||||
)}
|
||||
/>
|
||||
<HeaderLogo site={site} space={space} customization={customization} />
|
||||
{!hasSiteSections && isMultiVariants ? (
|
||||
<div className="z-20 shrink hidden sm:block">
|
||||
<SpacesDropdown space={space} spaces={spaces} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{customization.header.links.length > 0 && (
|
||||
<HeaderLinks>
|
||||
{customization.header.links.map((link, index) => {
|
||||
return (
|
||||
<HeaderLink
|
||||
key={index}
|
||||
link={link}
|
||||
context={context}
|
||||
customization={customization}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<HeaderLinkMore
|
||||
label={t(getSpaceLanguage(customization), 'more')}
|
||||
links={customization.header.links}
|
||||
context={context}
|
||||
customization={customization}
|
||||
/>
|
||||
</HeaderLinks>
|
||||
)}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'md:min-w-56',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'justify-self-end',
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<SearchButton
|
||||
style={
|
||||
!isCustomizationDefault && withTopHeader
|
||||
? [
|
||||
'bg-header-link/2',
|
||||
'dark:bg-header-link/2',
|
||||
'hover:bg-header-link/3',
|
||||
'dark:hover:bg-header-link/3',
|
||||
|
||||
'text-header-link/8',
|
||||
'dark:text-header-link/8',
|
||||
'hover:text-header-link',
|
||||
'dark:hover:text-header-link',
|
||||
|
||||
'ring-header-link/4',
|
||||
'dark:ring-header-link/4',
|
||||
'hover:ring-header-link/5',
|
||||
'dark:hover:ring-header-link/5',
|
||||
|
||||
'[&_svg]:text-header-link/10',
|
||||
'dark:[&_svg]:text-header-link/10',
|
||||
'[&_.shortcut]:text-header-link/8',
|
||||
'dark:[&_.shortcut]:text-header-link/8',
|
||||
|
||||
'contrast-more:bg-header-background',
|
||||
'contrast-more:text-header-link',
|
||||
'contrast-more:ring-header-link',
|
||||
'contrast-more:hover:bg-header-background',
|
||||
'contrast-more:hover:ring-header-link',
|
||||
'contrast-more:focus:text-header-link',
|
||||
'contrast-more:focus:bg-header-background',
|
||||
'contrast-more:focus:ring-header-link',
|
||||
'dark:contrast-more:bg-header-background',
|
||||
'dark:contrast-more:text-header-link',
|
||||
'dark:contrast-more:ring-header-link',
|
||||
'dark:contrast-more:hover:bg-header-background',
|
||||
'dark:contrast-more:hover:ring-header-link',
|
||||
'dark:contrast-more:focus:text-header-link',
|
||||
'dark:contrast-more:focus:bg-header-background',
|
||||
'dark:contrast-more:focus:ring-header-link',
|
||||
|
||||
'shadow-none',
|
||||
]
|
||||
: null
|
||||
}
|
||||
>
|
||||
<span className={tcls('flex-1')}>
|
||||
{t(
|
||||
getSpaceLanguage(customization),
|
||||
customization.aiSearch.enabled
|
||||
? 'search_or_ask'
|
||||
: 'search',
|
||||
)}
|
||||
...
|
||||
</span>
|
||||
</SearchButton>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{sections ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'scroll-nojump',
|
||||
'w-full',
|
||||
// Handle long section tabs, particularly on smaller screens.
|
||||
'overflow-x-auto hide-scroll',
|
||||
)}
|
||||
>
|
||||
<SiteSectionTabs {...sections} />
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import {
|
||||
CustomizationContentLink,
|
||||
CustomizationSettings,
|
||||
CustomizationHeaderPreset,
|
||||
SiteCustomizationSettings,
|
||||
CustomizationHeaderItem,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownButtonProps,
|
||||
DropdownChevron,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
} from './Dropdown';
|
||||
import { Button, Link } from '../primitives';
|
||||
|
||||
export async function HeaderLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationHeaderItem;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { context, link, customization } = props;
|
||||
|
||||
const target = link.to ? await resolveContentRef(link.to, context) : null;
|
||||
const headerPreset = customization.header.preset;
|
||||
const linkStyle = link.style ?? 'link';
|
||||
|
||||
if (link.links && link.links.length > 0) {
|
||||
return (
|
||||
<Dropdown
|
||||
className="shrink"
|
||||
button={(buttonProps) => {
|
||||
if (!target) {
|
||||
return (
|
||||
<HeaderItemDropdown
|
||||
{...buttonProps}
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HeaderLinkNavItem
|
||||
{...buttonProps}
|
||||
linkStyle={linkStyle}
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
isDropdown
|
||||
href={target?.href}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu>
|
||||
{link.links.map((subLink, index) => (
|
||||
<SubHeaderLink key={index} {...props} link={subLink} />
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HeaderLinkNavItem
|
||||
linkStyle={linkStyle}
|
||||
headerPreset={headerPreset}
|
||||
title={link.title}
|
||||
isDropdown={false}
|
||||
href={target.href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type HeaderLinkNavItemProps = {
|
||||
linkStyle: NonNullable<CustomizationHeaderItem['style']>;
|
||||
headerPreset: CustomizationHeaderPreset;
|
||||
title: string;
|
||||
href: string;
|
||||
isDropdown: boolean;
|
||||
} & DropdownButtonProps<HTMLElement>;
|
||||
|
||||
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
|
||||
switch (props.linkStyle) {
|
||||
case 'button-secondary':
|
||||
case 'button-primary':
|
||||
return <HeaderItemButton {...props} linkStyle={props.linkStyle} />;
|
||||
case 'link':
|
||||
return <HeaderItemLink {...props} />;
|
||||
default:
|
||||
assertNever(props.linkStyle);
|
||||
}
|
||||
}
|
||||
|
||||
function HeaderItemButton(
|
||||
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
|
||||
linkStyle: 'button-secondary' | 'button-primary';
|
||||
},
|
||||
) {
|
||||
const { linkStyle, headerPreset, title, href, isDropdown, ...rest } = props;
|
||||
const variant = (() => {
|
||||
switch (linkStyle) {
|
||||
case 'button-secondary':
|
||||
return 'secondary';
|
||||
case 'button-primary':
|
||||
return 'primary';
|
||||
default:
|
||||
assertNever(linkStyle);
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Button
|
||||
href={href}
|
||||
variant={variant}
|
||||
size="medium"
|
||||
className={tcls(
|
||||
{
|
||||
'button-primary':
|
||||
headerPreset === CustomizationHeaderPreset.Custom ||
|
||||
headerPreset === CustomizationHeaderPreset.Bold
|
||||
? tcls(
|
||||
'bg-header-link-500 hover:bg-text-header-link-300 text-header-button-text',
|
||||
'dark:bg-header-link-500 dark:hover:bg-text-header-link-300 dark:text-header-button-text',
|
||||
)
|
||||
: null,
|
||||
'button-secondary': tcls(
|
||||
'bg:transparent hover:bg-transparent',
|
||||
'dark:bg-transparent dark:hover:bg-transparent',
|
||||
'ring-header-link-500 hover:ring-header-link-300 text-header-link-500',
|
||||
'dark:ring-header-link-500 dark:hover:ring-header-link-300 dark:text-header-link-500',
|
||||
),
|
||||
}[linkStyle],
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset }) {
|
||||
return tcls(
|
||||
'flex items-center shrink',
|
||||
'hover:text-header-link-400 dark:hover:text-light',
|
||||
'min-w-0',
|
||||
|
||||
props.headerPreset === CustomizationHeaderPreset.Default
|
||||
? ['text-dark/8', 'dark:text-light/8']
|
||||
: ['text-header-link-500 hover:text-header-link-400'],
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderItemLink(props: HeaderLinkNavItemProps) {
|
||||
const { headerPreset, title, isDropdown, href, ...rest } = props;
|
||||
return (
|
||||
<Link href={href} className={getHeaderLinkClassName({ headerPreset })} {...rest}>
|
||||
<span className="truncate min-w-0">{title}</span>
|
||||
{isDropdown ? <DropdownChevron /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderItemDropdown(
|
||||
props: {
|
||||
headerPreset: CustomizationHeaderPreset;
|
||||
title: string;
|
||||
} & DropdownButtonProps<HTMLElement>,
|
||||
) {
|
||||
const { headerPreset, title, ...rest } = props;
|
||||
return (
|
||||
<span
|
||||
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default')}
|
||||
{...rest}
|
||||
>
|
||||
{title}
|
||||
<DropdownChevron />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function SubHeaderLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationContentLink;
|
||||
}) {
|
||||
const { context, link } = props;
|
||||
|
||||
const target = await resolveContentRef(link.to, context);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DropdownMenuItem href={target.href}>{link.title}</DropdownMenuItem>;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import {
|
||||
CustomizationContentLink,
|
||||
CustomizationHeaderItem,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSettings,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Dropdown, DropdownChevron, DropdownMenu, DropdownMenuItem } from './Dropdown';
|
||||
import styles from './headerLinks.module.css';
|
||||
|
||||
/**
|
||||
* Dropdown menu for header links hidden at small screen size.
|
||||
*/
|
||||
export function HeaderLinkMore(props: {
|
||||
label: React.ReactNode;
|
||||
links: CustomizationHeaderItem[];
|
||||
context: ContentRefContext;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}) {
|
||||
const { label, links, context, customization } = props;
|
||||
|
||||
const isCustomizationDefault =
|
||||
customization.header.preset === CustomizationHeaderPreset.Default;
|
||||
|
||||
const renderButton = () => (
|
||||
<button
|
||||
className={tcls(
|
||||
isCustomizationDefault
|
||||
? [
|
||||
'text-dark/8',
|
||||
'dark:text-light/8',
|
||||
'hover:text-primary',
|
||||
'dark:hover:text-primary',
|
||||
]
|
||||
: ['text-header-link', 'hover:text-header-link/8'],
|
||||
'flex',
|
||||
'gap-1',
|
||||
'items-center',
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<Icon icon="ellipsis" className={tcls('size-4')} />
|
||||
<DropdownChevron />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${styles.linkEllipsis} items-center z-20`}>
|
||||
<Dropdown button={renderButton} className="-translate-x-48 md:translate-x-0">
|
||||
<DropdownMenu>
|
||||
{links.map((link, index) => (
|
||||
<MoreMenuLink key={index} link={link} context={context} />
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function MoreMenuLink(props: {
|
||||
context: ContentRefContext;
|
||||
link: CustomizationHeaderItem | CustomizationContentLink;
|
||||
}) {
|
||||
const { context, link } = props;
|
||||
|
||||
const target = link.to ? await resolveContentRef(link.to, context) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{'links' in link && link.links.length > 0 && (
|
||||
<hr className="first:hidden border-t border-light-3 dark:border-dark-3 my-1 -mx-2" />
|
||||
)}
|
||||
<DropdownMenuItem href={target?.href ?? null}>{link.title}</DropdownMenuItem>
|
||||
{'links' in link
|
||||
? link.links.map((subLink, index) => (
|
||||
<MoreMenuLink key={index} {...props} link={subLink} />
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import styles from './headerLinks.module.css';
|
||||
|
||||
interface HeaderLinksProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export async function HeaderLinks({ children }: HeaderLinksProps) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
styles.containerHeaderlinks,
|
||||
'grow shrink flex justify-end items-center gap-x-6 lg:gap-x-8 min-w-9 z-20',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSettings,
|
||||
Site,
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
|
||||
import { HeaderMobileMenu } from '@/components/Header/HeaderMobileMenu';
|
||||
import { Image } from '@/components/utils';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { getContentTitle } from '@/lib/utils';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
import { SpaceIcon } from '../Space/SpaceIcon';
|
||||
|
||||
interface HeaderLogoProps {
|
||||
site: Site | null;
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the logo for a space using the customization settings.
|
||||
*/
|
||||
|
||||
export function HeaderLogo(props: HeaderLogoProps) {
|
||||
const { customization } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={absoluteHref('')}
|
||||
className={tcls('group/headerlogo', 'min-w-0', 'shrink', 'flex', 'items-center')}
|
||||
>
|
||||
{customization.header.logo ? (
|
||||
<Image
|
||||
alt="Logo"
|
||||
sources={{
|
||||
light: {
|
||||
src: customization.header.logo.light,
|
||||
},
|
||||
dark: customization.header.logo.dark
|
||||
? {
|
||||
src: customization.header.logo.dark,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
sizes={[
|
||||
{
|
||||
media: '(max-width: 1024px)',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
width: 260,
|
||||
},
|
||||
]}
|
||||
priority="high"
|
||||
style={tcls(
|
||||
'rounded',
|
||||
'straight-corners:rounded-sm',
|
||||
'overflow-hidden',
|
||||
'shrink',
|
||||
'min-w-0',
|
||||
'max-w-40',
|
||||
'lg:max-w-64',
|
||||
'max-h-10',
|
||||
'lg:max-h-12',
|
||||
'h-full',
|
||||
'w-auto',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<LogoFallback {...props} />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoFallback(props: HeaderLogoProps) {
|
||||
const { site, space, customization } = props;
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : undefined;
|
||||
const customEmoji = 'emoji' in customization.favicon ? customization.favicon.emoji : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpaceIcon
|
||||
icon={customIcon}
|
||||
emoji={customEmoji}
|
||||
alt=""
|
||||
sizes={[{ width: 32 }]}
|
||||
style={['object-contain', 'size-8']}
|
||||
fetchPriority="high"
|
||||
/>
|
||||
<div
|
||||
className={tcls(
|
||||
'text-pretty',
|
||||
'line-clamp-3',
|
||||
'tracking-tight',
|
||||
'max-w-[18ch]',
|
||||
'lg:max-w-[24ch]',
|
||||
'font-semibold',
|
||||
'ms-3',
|
||||
'text-base/tight',
|
||||
'lg:text-lg/tight',
|
||||
customization.header.preset === CustomizationHeaderPreset.Default ||
|
||||
customization.header.preset === CustomizationHeaderPreset.None
|
||||
? ['text-dark', 'dark:text-light']
|
||||
: 'text-header-link',
|
||||
)}
|
||||
>
|
||||
{getContentTitle(space, customization, site)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Space } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Dropdown, DropdownChevron, DropdownMenu } from './Dropdown';
|
||||
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
|
||||
|
||||
export function SpacesDropdown(props: { space: Space; spaces: Space[]; className?: string }) {
|
||||
const { space, spaces, className } = props;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
button={(buttonProps) => (
|
||||
<div
|
||||
{...buttonProps}
|
||||
data-testid="space-dropdown-button"
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'rounded-2xl',
|
||||
'straight-corners:rounded-none',
|
||||
|
||||
'hover:cursor-pointer',
|
||||
'bg-dark/1',
|
||||
'dark:bg-light/1',
|
||||
|
||||
'text-sm',
|
||||
'text-dark-4',
|
||||
'dark:text-light-4',
|
||||
|
||||
'contrast-more:bg-light',
|
||||
'contrast-more:ring-1',
|
||||
'contrast-more:ring-dark',
|
||||
'dark:contrast-more:ring-light',
|
||||
'dark:contrast-more:bg-dark',
|
||||
|
||||
'px-3',
|
||||
'py-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-2">{space.title}</span>
|
||||
<DropdownChevron />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
{spaces.map((otherSpace, index) => (
|
||||
<SpacesDropdownMenuItem
|
||||
key={`${otherSpace.id}-${index}`}
|
||||
variantSpace={{
|
||||
id: otherSpace.id,
|
||||
title: otherSpace.title,
|
||||
url: otherSpace.urls.published ?? otherSpace.urls.app,
|
||||
}}
|
||||
active={otherSpace.id === space.id}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Space } from '@gitbook/api';
|
||||
import { useSelectedLayoutSegment } from 'next/navigation';
|
||||
|
||||
import { DropdownMenuItem } from './Dropdown';
|
||||
|
||||
function useVariantSpaceHref(variantSpaceUrl: string) {
|
||||
const currentPathname = useSelectedLayoutSegment() ?? '';
|
||||
const targetUrl = new URL(variantSpaceUrl);
|
||||
targetUrl.pathname += `/${currentPathname}`;
|
||||
targetUrl.pathname = targetUrl.pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '');
|
||||
targetUrl.searchParams.set('fallback', 'true');
|
||||
|
||||
return targetUrl.toString();
|
||||
}
|
||||
|
||||
export function SpacesDropdownMenuItem(props: {
|
||||
variantSpace: { id: Space['id']; title: Space['title']; url: string };
|
||||
active: boolean;
|
||||
}) {
|
||||
const { variantSpace, active } = props;
|
||||
const variantHref = useVariantSpaceHref(variantSpace.url);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={variantSpace.id} href={variantHref} active={active}>
|
||||
{variantSpace.title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
.containerHeaderlinks {
|
||||
container-type: inline-size;
|
||||
container-name: headerlinks;
|
||||
}
|
||||
|
||||
.linkEllipsis {
|
||||
display: none;
|
||||
& div > a {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container headerlinks ( width < 150px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 1) {
|
||||
display: none;
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 1) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > a:nth-of-type(n + 1) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 300px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 2) {
|
||||
display: none;
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 2) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > a:nth-of-type(n + 2) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 450px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 3) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > a:nth-of-type(n + 3) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 600px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 4) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > a:nth-of-type(n + 4) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 750px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 5) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > a:nth-of-type(n + 5) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 850px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 6) {
|
||||
display: none;
|
||||
}
|
||||
.containerHeaderlinks > :nth-child(n + 6) ~ .linkEllipsis {
|
||||
display: flex;
|
||||
& div > a:nth-of-type(n + 6) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Transition, motion, useReducedMotion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
export function AnimatedLine({ transition }: { transition?: Transition }) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
layoutId="sections-line"
|
||||
className={tcls([
|
||||
'border-primary',
|
||||
'border-l',
|
||||
'dark:border-primary-400',
|
||||
'h-full',
|
||||
'absolute',
|
||||
'z-20',
|
||||
'-left-[5px]',
|
||||
])}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : transition}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { RevisionPage, RevisionPageDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { pageHref } from '@/lib/links';
|
||||
import { AncestorRevisionPage } from '@/lib/pages';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PageIcon } from '../PageIcon';
|
||||
import { StyledLink } from '../primitives';
|
||||
|
||||
export function PageHeader(props: {
|
||||
page: RevisionPageDocument;
|
||||
ancestors: AncestorRevisionPage[];
|
||||
pages: RevisionPage[];
|
||||
}) {
|
||||
const { page, ancestors, pages } = props;
|
||||
|
||||
if (!page.layout.title && !page.layout.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={tcls('max-w-3xl', 'mx-auto', 'mb-6', 'space-y-3', 'page-api-block:ml-0')}
|
||||
>
|
||||
{ancestors.length > 0 && (
|
||||
<nav>
|
||||
<ol className={tcls('flex', 'flex-wrap', 'items-center', 'gap-2')}>
|
||||
{ancestors.map((breadcrumb, index) => (
|
||||
<>
|
||||
<li key={breadcrumb.id}>
|
||||
<StyledLink
|
||||
href={pageHref(pages, breadcrumb)}
|
||||
style={tcls(
|
||||
'no-underline',
|
||||
'hover:underline',
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'font-semibold',
|
||||
'uppercase',
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-1',
|
||||
)}
|
||||
>
|
||||
<PageIcon
|
||||
page={breadcrumb}
|
||||
style={tcls('size-4', 'text-base', 'leading-none')}
|
||||
/>
|
||||
{breadcrumb.title}
|
||||
</StyledLink>
|
||||
</li>
|
||||
{index != ancestors.length - 1 && (
|
||||
<Icon
|
||||
icon="chevron-right"
|
||||
className={tcls(
|
||||
'size-3',
|
||||
'text-light-4',
|
||||
'dark:text-dark-4',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
{page.layout.title ? (
|
||||
<h1 className={tcls('text-4xl', 'font-bold', 'flex', 'items-center', 'gap-4')}>
|
||||
<PageIcon page={page} style={['text-dark/6', 'dark:text-light/6']} />
|
||||
{page.title}
|
||||
</h1>
|
||||
) : null}
|
||||
{page.description && page.layout.description ? (
|
||||
<p className={tcls('text-lg', 'text-dark-4', 'dark:text-light-4')}>
|
||||
{page.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* This component preserves the layout of the page while loading a new one.
|
||||
* This approach is needed as page layout (full width block) is done using CSS (`body:has(.page-full-width)`),
|
||||
* which becomes false while transitioning between the 2 page states:
|
||||
*
|
||||
* 1. Page 1 with full width block: `body:has(.page-full-width)` is true
|
||||
* 2. Loading skeleton while transitioning to page 2: `body:has(.page-full-width)` is false
|
||||
* 3. Page 2 with full width block: `body:has(.page-full-width)` is true
|
||||
*
|
||||
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
||||
*/
|
||||
export function PreservePageLayout(props: { asFullWidth: boolean }) {
|
||||
const { asFullWidth } = props;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// We use the header as it's an element preserved between page transitions
|
||||
// (rendered in the layout component).
|
||||
const header = document.querySelector('header');
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asFullWidth) {
|
||||
header.classList.add('page-full-width');
|
||||
} else {
|
||||
header.classList.remove('page-full-width');
|
||||
}
|
||||
}, [asFullWidth]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { RequestSiteTrackPageView, RequestSpaceTrackPageView } from '@gitbook/api';
|
||||
import cookies from 'js-cookie';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getVisitorId } from '@/lib/analytics';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Track the page view for the current page to integrations.
|
||||
*/
|
||||
export function TrackPageView(props: {
|
||||
apiHost: string;
|
||||
sitePointer: SiteContentPointer;
|
||||
spaceId: string;
|
||||
pageId: string | undefined;
|
||||
}) {
|
||||
const { apiHost, sitePointer, spaceId, pageId } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
trackPageView({ apiHost, sitePointer, spaceId, pageId });
|
||||
}, [apiHost, spaceId, pageId, sitePointer]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendSpaceTrackPageViewRequest(args: {
|
||||
apiHost: string;
|
||||
spaceId: string;
|
||||
body: RequestSpaceTrackPageView;
|
||||
}) {
|
||||
const { apiHost, spaceId, body } = args;
|
||||
const url = new URL(apiHost);
|
||||
url.pathname = `/v1/spaces/${spaceId}/insights/track_view`;
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSiteTrackPageViewRequest(args: {
|
||||
apiHost: string;
|
||||
sitePointer: SiteContentPointer;
|
||||
body: RequestSiteTrackPageView;
|
||||
}) {
|
||||
const { apiHost, sitePointer, body } = args;
|
||||
const url = new URL(apiHost);
|
||||
url.pathname = `/v1/orgs/${sitePointer.organizationId}/sites/${sitePointer.siteId}/insights/track_view`;
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
let latestPageId: string | undefined | null = null;
|
||||
|
||||
/**
|
||||
* Track the page view for the current page to GitBook.
|
||||
* We don't use the API client to avoid shipping 80kb of JS to the client.
|
||||
* And instead use a simple fetch.
|
||||
*/
|
||||
async function trackPageView(args: {
|
||||
apiHost: string;
|
||||
sitePointer: SiteContentPointer;
|
||||
spaceId: string;
|
||||
pageId: string | undefined;
|
||||
}) {
|
||||
const { apiHost, sitePointer, pageId, spaceId } = args;
|
||||
if (pageId === latestPageId) {
|
||||
// The hook can be called multiple times, we only want to track once.
|
||||
return;
|
||||
}
|
||||
|
||||
latestPageId = pageId;
|
||||
|
||||
const visitorId = await getVisitorId();
|
||||
const sharedTrackedProps = {
|
||||
url: window.location.href,
|
||||
pageId,
|
||||
visitor: {
|
||||
anonymousId: visitorId,
|
||||
userAgent: window.navigator.userAgent,
|
||||
language: window.navigator.language,
|
||||
cookies: cookies.get(),
|
||||
},
|
||||
referrer: document.referrer,
|
||||
};
|
||||
|
||||
try {
|
||||
sitePointer
|
||||
? await sendSiteTrackPageViewRequest({
|
||||
apiHost,
|
||||
sitePointer,
|
||||
body: {
|
||||
...sharedTrackedProps,
|
||||
spaceId,
|
||||
},
|
||||
})
|
||||
: await sendSpaceTrackPageViewRequest({ apiHost, spaceId, body: sharedTrackedProps });
|
||||
} catch (error) {
|
||||
console.error('Failed to track page view', error);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { PageFeedbackRating } from '@gitbook/api';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
import { api } from '@/lib/api';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
|
||||
export async function postPageFeedback(args: {
|
||||
pageId: string;
|
||||
visitorId: string;
|
||||
rating: PageFeedbackRating;
|
||||
}) {
|
||||
const { organizationId, siteId, siteSpaceId } = getSiteContentPointer();
|
||||
|
||||
assert(
|
||||
siteSpaceId,
|
||||
`No siteSpaceId in pointer. organizationId: ${organizationId}, siteId: ${siteId}, pageId: ${args.pageId}`,
|
||||
);
|
||||
|
||||
await api().client.orgs.createSitesPageFeedback(
|
||||
organizationId,
|
||||
siteId,
|
||||
siteSpaceId,
|
||||
args.pageId,
|
||||
args.visitorId,
|
||||
{
|
||||
rating: args.rating,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { RevisionPage } from '@gitbook/api';
|
||||
import { Icon, IconName } from '@gitbook/icons';
|
||||
|
||||
import { Emoji } from '@/components/primitives';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
export function PageIcon(props: { page: RevisionPage; style?: ClassValue }) {
|
||||
const { page, style } = props;
|
||||
|
||||
if (page.emoji) {
|
||||
return (
|
||||
<Emoji
|
||||
code={page.emoji}
|
||||
style={[
|
||||
style,
|
||||
// We reset the color that could be passed as "style"
|
||||
// as emojis should always be rendered with normal text opacity
|
||||
'text-inherit',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (page.icon) {
|
||||
return <Icon icon={page.icon as IconName} className={tcls('size-[1em]', style)} />;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './PageIcon';
|
||||
@@ -1,305 +0,0 @@
|
||||
import {
|
||||
CustomizationCorners,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationIconsStyle,
|
||||
CustomizationSettings,
|
||||
CustomizationTint,
|
||||
SiteCustomizationSettings,
|
||||
} from '@gitbook/api';
|
||||
import { IconsProvider, IconStyle } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import colorContrast from 'postcss-color-contrast/js';
|
||||
import colors from 'tailwindcss/colors';
|
||||
|
||||
import { fonts, ibmPlexMono } from '@/fonts';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { getStaticFileURL } from '@/lib/assets';
|
||||
import { hexToRgb, shadesOfColor } from '@/lib/colors';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { emojiFontClassName } from '../primitives';
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
|
||||
import '@gitbook/icons/style.css';
|
||||
import './globals.css';
|
||||
|
||||
const DEFAULT_TINT_COLOR = '#787878';
|
||||
|
||||
/**
|
||||
* Layout shared between the content and the PDF renderer.
|
||||
* It takes care of setting the theme and the language.
|
||||
*/
|
||||
export async function CustomizationRootLayout(props: {
|
||||
customization: SiteCustomizationSettings | CustomizationSettings;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { customization, children } = props;
|
||||
|
||||
const headerTheme = generateHeaderTheme(customization);
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const tintColor = getTintColor(customization);
|
||||
|
||||
return (
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
lang={customization.internationalization.locale}
|
||||
className={tcls(
|
||||
customization.header.preset === CustomizationHeaderPreset.None
|
||||
? null
|
||||
: 'scroll-pt-[76px]', // Take the sticky header in consideration for the scrolling
|
||||
customization.styling.corners === CustomizationCorners.Straight
|
||||
? ' straight-corners'
|
||||
: '',
|
||||
tintColor ? ' tint' : 'no-tint',
|
||||
)}
|
||||
>
|
||||
<head>
|
||||
{customization.privacyPolicy.url ? (
|
||||
<link rel="privacy-policy" href={customization.privacyPolicy.url} />
|
||||
) : null}
|
||||
<style
|
||||
nonce={
|
||||
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
|
||||
undefined
|
||||
}
|
||||
>{`
|
||||
:root {
|
||||
${generateColorVariable(
|
||||
'primary-color',
|
||||
customization.styling.primaryColor.light,
|
||||
)}
|
||||
${
|
||||
// Generate the right contrast color for each shade of primary-color
|
||||
generateColorVariable(
|
||||
'contrast-primary',
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
shadesOfColor(customization.styling.primaryColor.light),
|
||||
).map(([index, color]) => [
|
||||
index,
|
||||
colorContrast(color, ['#000', '#fff']),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
${generateColorVariable('tint-color', tintColor?.light ?? DEFAULT_TINT_COLOR)}
|
||||
${
|
||||
// Generate the right contrast color for each shade of tint-color
|
||||
generateColorVariable(
|
||||
'contrast-tint',
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
shadesOfColor(tintColor?.light || DEFAULT_TINT_COLOR),
|
||||
).map(([index, color]) => [
|
||||
index,
|
||||
colorContrast(color, ['#000', '#fff']),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
${generateColorVariable(
|
||||
'header-background',
|
||||
headerTheme.backgroundColor.light,
|
||||
)}
|
||||
${generateColorVariable('header-link', headerTheme.linkColor.light)}
|
||||
${generateColorVariable('header-button-text', colorContrast(headerTheme.linkColor.light as string, ['#000', '#fff']))}
|
||||
}
|
||||
.dark {
|
||||
${generateColorVariable(
|
||||
'primary-color',
|
||||
customization.styling.primaryColor.dark,
|
||||
)}
|
||||
${
|
||||
// Generate the right contrast color for each shade of primary-color
|
||||
generateColorVariable(
|
||||
'contrast-primary',
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
shadesOfColor(customization.styling.primaryColor.dark),
|
||||
).map(([index, color]) => [
|
||||
index,
|
||||
colorContrast(color, ['#000', '#fff']),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
${generateColorVariable('tint-color', tintColor?.dark ?? DEFAULT_TINT_COLOR)}
|
||||
${
|
||||
// Generate the right contrast color for each shade of tint-color
|
||||
generateColorVariable(
|
||||
'contrast-tint',
|
||||
Object.fromEntries(
|
||||
Object.entries(
|
||||
shadesOfColor(tintColor?.dark || DEFAULT_TINT_COLOR),
|
||||
).map(([index, color]) => [
|
||||
index,
|
||||
colorContrast(color, ['#000', '#fff']),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
${generateColorVariable(
|
||||
'header-background',
|
||||
headerTheme.backgroundColor.dark,
|
||||
)}
|
||||
${generateColorVariable('header-link', headerTheme.linkColor.dark)}
|
||||
${generateColorVariable('header-button-text', colorContrast(headerTheme.linkColor.dark as string, ['#000', '#fff']))}
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body
|
||||
className={tcls(
|
||||
emojiFontClassName,
|
||||
`${fonts[customization.styling.font].className}`,
|
||||
`${ibmPlexMono.variable}`,
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
)}
|
||||
>
|
||||
<IconsProvider
|
||||
assetsURL={process.env.GITBOOK_ICONS_URL ?? getStaticFileURL('icons')}
|
||||
assetsURLToken={process.env.GITBOOK_ICONS_TOKEN}
|
||||
assetsByStyles={{
|
||||
'custom-icons': {
|
||||
assetsURL: getStaticFileURL('icons'),
|
||||
},
|
||||
}}
|
||||
iconStyle={
|
||||
('icons' in customization.styling
|
||||
? apiToIconsStyles[customization.styling.icons]
|
||||
: null) || IconStyle.Regular
|
||||
}
|
||||
>
|
||||
<ClientContexts language={language}>{children}</ClientContexts>
|
||||
</IconsProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tint color from the customization settings.
|
||||
* If the tint color is not set or it is a space customization, it will return the default color.
|
||||
*/
|
||||
export function getTintColor(
|
||||
customization: CustomizationSettings | SiteCustomizationSettings,
|
||||
): CustomizationTint['color'] | undefined {
|
||||
if ('tint' in customization.styling && customization.styling.tint) {
|
||||
return {
|
||||
light: customization.styling.tint?.color.light ?? DEFAULT_TINT_COLOR,
|
||||
dark: customization.styling.tint?.color.dark ?? DEFAULT_TINT_COLOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ColorInput = string | Record<string, string>;
|
||||
function generateColorVariable(name: string, color: ColorInput) {
|
||||
const shades: Record<string, string> = typeof color === 'string' ? shadesOfColor(color) : color;
|
||||
|
||||
return Object.entries(shades)
|
||||
.map(([key, value]) => {
|
||||
// Check the original hex value
|
||||
const rgbValue = hexToRgb(value);
|
||||
return `--${name}-${key}: ${rgbValue};`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function generateHeaderTheme(customization: CustomizationSettings | SiteCustomizationSettings): {
|
||||
backgroundColor: { light: ColorInput; dark: ColorInput };
|
||||
linkColor: { light: ColorInput; dark: ColorInput };
|
||||
} {
|
||||
const tintColor = getTintColor(customization);
|
||||
|
||||
switch (customization.header.preset) {
|
||||
case CustomizationHeaderPreset.None:
|
||||
case CustomizationHeaderPreset.Default: {
|
||||
return {
|
||||
backgroundColor: {
|
||||
light: colors.white,
|
||||
dark: colors.black,
|
||||
},
|
||||
linkColor: {
|
||||
light: customization.styling.primaryColor.light,
|
||||
dark: customization.styling.primaryColor.dark,
|
||||
},
|
||||
};
|
||||
}
|
||||
case CustomizationHeaderPreset.Bold: {
|
||||
return {
|
||||
backgroundColor: {
|
||||
light: tintColor?.light ?? customization.styling.primaryColor.light,
|
||||
dark: tintColor?.dark ?? customization.styling.primaryColor.dark,
|
||||
},
|
||||
linkColor: {
|
||||
light: colorContrast(
|
||||
tintColor?.light ?? customization.styling.primaryColor.light,
|
||||
[colors.white, colors.black],
|
||||
'aaa',
|
||||
),
|
||||
dark: colorContrast(
|
||||
tintColor?.dark ?? customization.styling.primaryColor.dark,
|
||||
[colors.white, colors.black],
|
||||
'aaa',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
case CustomizationHeaderPreset.Contrast: {
|
||||
return {
|
||||
backgroundColor: {
|
||||
light: colors.black,
|
||||
dark: colors.white,
|
||||
},
|
||||
linkColor: {
|
||||
light: colors.white,
|
||||
dark: colors.black,
|
||||
},
|
||||
};
|
||||
}
|
||||
case CustomizationHeaderPreset.Custom: {
|
||||
return {
|
||||
backgroundColor: {
|
||||
light:
|
||||
customization.header.backgroundColor?.light ??
|
||||
tintColor?.light ??
|
||||
colors.white,
|
||||
dark:
|
||||
customization.header.backgroundColor?.dark ??
|
||||
tintColor?.dark ??
|
||||
colors.black,
|
||||
},
|
||||
linkColor: {
|
||||
light:
|
||||
customization.header.linkColor?.light ??
|
||||
(tintColor?.light &&
|
||||
colorContrast(tintColor.light, [colors.white, colors.black], 'aaa')) ??
|
||||
customization.styling.primaryColor.light,
|
||||
dark:
|
||||
customization.header.linkColor?.dark ??
|
||||
(tintColor?.dark &&
|
||||
colorContrast(tintColor.dark, [colors.white, colors.black], 'aaa')) ??
|
||||
customization.styling.primaryColor.dark,
|
||||
},
|
||||
};
|
||||
}
|
||||
default: {
|
||||
assertNever(customization.header.preset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiToIconsStyles: {
|
||||
[key in CustomizationIconsStyle]: IconStyle;
|
||||
} = {
|
||||
[CustomizationIconsStyle.Regular]: IconStyle.Regular,
|
||||
[CustomizationIconsStyle.Solid]: IconStyle.Solid,
|
||||
[CustomizationIconsStyle.Duotone]: IconStyle.Duotone,
|
||||
[CustomizationIconsStyle.Thin]: IconStyle.Thin,
|
||||
[CustomizationIconsStyle.Light]: IconStyle.Light,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './CustomizationRootLayout';
|
||||
@@ -1,289 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { atom, useRecoilState } from 'recoil';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import { TranslationLanguage } from '@/intl/translations';
|
||||
import { iterateStreamResponse } from '@/lib/actions';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
import { useSearch, useSearchLink } from './useSearch';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
type SearchState =
|
||||
| {
|
||||
type: 'answer';
|
||||
answer: AskAnswerResult;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
}
|
||||
| {
|
||||
type: 'loading';
|
||||
};
|
||||
|
||||
/**
|
||||
- * Store the state of the answer in a global state so that it can be
|
||||
- * accessed from anywhere to show a loading indicator.
|
||||
- */
|
||||
export const searchAskState = atom<SearchState | null>({
|
||||
key: 'searchAskState',
|
||||
default: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch and render the answers to a question.
|
||||
*/
|
||||
export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: string }) {
|
||||
const { pointer, query } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
const [state, setState] = useRecoilState(searchAskState);
|
||||
const { organizationId, siteId, siteSpaceId } = pointer;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setState({
|
||||
type: 'loading',
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const stream = iterateStreamResponse(
|
||||
streamAskQuestion(organizationId, siteId, siteSpaceId ?? null, query),
|
||||
);
|
||||
|
||||
setSearchState((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
ask: true,
|
||||
query,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
type: 'answer',
|
||||
answer: chunk,
|
||||
});
|
||||
}
|
||||
})().catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
type: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
// During development, the useEffect is called twice and the second call doesn't process the stream,
|
||||
// causing the component to get stuck in the loading state.
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
}, [organizationId, siteId, siteSpaceId, query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
setState(null);
|
||||
};
|
||||
}, [setState]);
|
||||
|
||||
const loading = (
|
||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
||||
<Loading className={tcls('w-6', 'py-8', 'text-primary')} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tcls('max-h-[60vh]', 'overflow-y-auto')}>
|
||||
{state?.type === 'answer' ? (
|
||||
<React.Suspense fallback={loading}>
|
||||
<TransitionAnswerBody answer={state.answer} placeholder={loading} />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
{state?.type === 'error' ? (
|
||||
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
||||
) : null}
|
||||
{state?.type === 'loading' ? loading : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since the answer can be an async component that could suspend rendering,
|
||||
* we need to wrap it in a transition to avoid flickering.
|
||||
*/
|
||||
function TransitionAnswerBody(props: { answer: AskAnswerResult; placeholder: React.ReactNode }) {
|
||||
const { answer, placeholder } = props;
|
||||
const [display, setDisplay] = React.useState<AskAnswerResult | null>(null);
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
startTransition(() => {
|
||||
setDisplay(answer);
|
||||
});
|
||||
}, [answer]);
|
||||
|
||||
return display ? (
|
||||
<div className={tcls('w-full')}>
|
||||
<AnswerBody answer={display} />
|
||||
</div>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswerBody(props: { answer: AskAnswerResult }) {
|
||||
const { answer } = props;
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-test="search-ask-answer"
|
||||
className={tcls(
|
||||
'my-4',
|
||||
'sm:mt-6',
|
||||
'px-4',
|
||||
'sm:px-12',
|
||||
'text-dark/9',
|
||||
'dark:text-light/8',
|
||||
)}
|
||||
>
|
||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||
{answer.followupQuestions.length > 0 ? (
|
||||
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
||||
) : null}
|
||||
</div>
|
||||
{answer.sources.length > 0 ? (
|
||||
<AnswerSources
|
||||
sources={answer.sources}
|
||||
language={language}
|
||||
hasAnswer={Boolean(answer.body)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswerFollowupQuestions(props: { followupQuestions: string[] }) {
|
||||
const { followupQuestions } = props;
|
||||
const getSearchLinkProps = useSearchLink();
|
||||
|
||||
return (
|
||||
<div className={tcls('flex', 'flex-col', 'flex-wrap', 'mt-4', 'sm:mt-6')}>
|
||||
{followupQuestions.map((question) => (
|
||||
<Link
|
||||
key={question}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
'px-4',
|
||||
'-mx-4',
|
||||
'py-2',
|
||||
'rounded',
|
||||
'straight-corners:rounded-none',
|
||||
'text-dark/7',
|
||||
'dark:text-light/8',
|
||||
'hover:bg-dark-4/2',
|
||||
'dark:hover:bg-light-4/2',
|
||||
'focus-within:bg-dark-4/2',
|
||||
'dark:focus-within:bg-light-4/2',
|
||||
)}
|
||||
{...getSearchLinkProps({
|
||||
query: question,
|
||||
ask: true,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
icon="magnifying-glass"
|
||||
className={tcls(
|
||||
'size-4',
|
||||
'shrink-0',
|
||||
'mr-2',
|
||||
'text-dark/5',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
/>
|
||||
<span>{question}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswerSources(props: {
|
||||
sources: AskAnswerSource[];
|
||||
language: TranslationLanguage;
|
||||
hasAnswer: boolean;
|
||||
}) {
|
||||
const { sources, language, hasAnswer } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-wrap',
|
||||
'gap-2',
|
||||
'mt-4',
|
||||
'sm:mt-6',
|
||||
'py-4',
|
||||
'px-4',
|
||||
'border-t',
|
||||
'border-dark/2',
|
||||
'dark:border-light/1',
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{t(language, hasAnswer ? 'search_ask_sources' : 'search_ask_sources_no_answer')}
|
||||
</span>
|
||||
|
||||
{sources.map((source) => (
|
||||
<span key={source.id} className={tcls()}>
|
||||
<Link
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-wrap',
|
||||
'gap-1',
|
||||
'items-center',
|
||||
'text-dark/7',
|
||||
'hover:underline',
|
||||
'focus-within:text-primary-700',
|
||||
'dark:text-light/8',
|
||||
)}
|
||||
href={source.href}
|
||||
prefetch={false}
|
||||
>
|
||||
<Icon
|
||||
icon="arrow-up-right"
|
||||
className={tcls(
|
||||
'text-dark/6',
|
||||
'size-4',
|
||||
'shrink-0',
|
||||
'dark:text-light/6',
|
||||
)}
|
||||
/>
|
||||
{source.title}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useLanguage, tString } from '@/intl/client';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
/**
|
||||
* Button to open the search modal.
|
||||
*/
|
||||
export function SearchButton(props: { children?: React.ReactNode; style?: ClassValue }) {
|
||||
const { style, children } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
const onClick = () => {
|
||||
setSearchState({
|
||||
ask: false,
|
||||
global: false,
|
||||
query: '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={tString(language, 'search')}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-1',
|
||||
'flex-row',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'w-full',
|
||||
'px-3',
|
||||
'py-2',
|
||||
'gap-2',
|
||||
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
|
||||
'ring-1',
|
||||
'ring-dark/1',
|
||||
'dark:ring-light/2',
|
||||
|
||||
'shadow-sm',
|
||||
'shadow-dark/4',
|
||||
'dark:shadow-none',
|
||||
|
||||
'text-dark/6',
|
||||
'dark:text-light/6',
|
||||
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-sm',
|
||||
|
||||
'contrast-more:ring-dark',
|
||||
'contrast-more:text-dark',
|
||||
'contrast-more:dark:ring-light',
|
||||
'contrast-more:dark:text-light',
|
||||
|
||||
'transition-all',
|
||||
'hover:shadow-md',
|
||||
'hover:scale-102',
|
||||
'hover:ring-dark/2',
|
||||
'hover:text-dark/10',
|
||||
'focus:shadow-md',
|
||||
'focus:scale-102',
|
||||
'focus:ring-dark/2',
|
||||
'focus:text-dark/10',
|
||||
'dark:hover:bg-dark-3',
|
||||
'dark:hover:ring-light/4',
|
||||
'dark:hover:text-light',
|
||||
'dark:focus:bg-dark-3',
|
||||
'dark:focus:ring-light/4',
|
||||
'dark:focus:text-light',
|
||||
|
||||
'contrast-more:hover:ring-2',
|
||||
'contrast-more:hover:ring-dark',
|
||||
'dark:contrast-more:hover:ring-light',
|
||||
'contrast-more:focus:ring-2',
|
||||
'contrast-more:focus:ring-dark',
|
||||
'dark:contrast-more:focus:ring-light',
|
||||
|
||||
'active:shadow-sm',
|
||||
'active:scale-98',
|
||||
|
||||
'md:justify-start',
|
||||
'md:w-full',
|
||||
style,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon="magnifying-glass"
|
||||
className={tcls('text-dark/8', 'dark:text-light/8', 'shrink-0', 'size-4')}
|
||||
/>
|
||||
<div className={tcls('w-full', 'hidden', 'md:block', 'text-left')}>{children}</div>
|
||||
<Shortcut />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const Shortcut = () => {
|
||||
const [operatingSystem, setOperatingSystem] = useState('win');
|
||||
|
||||
useEffect(() => {
|
||||
function getOperatingSystem() {
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
|
||||
if (platform.includes('mac')) return 'mac';
|
||||
if (platform.includes('win')) return 'win';
|
||||
|
||||
return 'win';
|
||||
}
|
||||
|
||||
setOperatingSystem(getOperatingSystem());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'shortcut',
|
||||
'hidden',
|
||||
'md:inline',
|
||||
'justify-end',
|
||||
'text-xs',
|
||||
'text-dark/6',
|
||||
'contrast-more:text-dark',
|
||||
'dark:text-light/6',
|
||||
'whitespace-nowrap',
|
||||
'contrast-more:dark:text-light',
|
||||
`[font-feature-settings:"calt",_"case"]`,
|
||||
)}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl +'} K
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user