mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 07:35:16 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c924e296aa | |||
| e0f7190229 | |||
| a65654edfb | |||
| 3c21a5e820 | |||
| 71141d7f26 | |||
| ece3f2078e | |||
| 05627965fb | |||
| 41cba63f27 |
+1
-1
@@ -1 +1 @@
|
||||
1.2.1
|
||||
1.1.40
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook-v2': minor
|
||||
---
|
||||
|
||||
Setup structure and deployment for new version
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Support llms.txt
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Track new events for site insights when ads are being clicked
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Support response examples
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix default outline list styling
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Cache API spec for 24 hours, revalidated every 2 hours
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Do not dereference before caching OpenAPI spec.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Fix an issue where a response object using a special ref would crash the page.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add feedback form to page rating control
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
New highlight colors
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Improve the display of recommended questions by streaming them.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Improve performances by loading Scalar API Client only when the button is clicked
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Grow search bar conditionally if <5 header links are present
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': minor
|
||||
'gitbook': minor
|
||||
---
|
||||
|
||||
Fix internal properties appearing in OpenAPI docs.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': major
|
||||
---
|
||||
|
||||
Use `@scalar/openapi-parser` to be more resilient and perf on OpenAPI spec parsing:
|
||||
|
||||
- `fetcher.fetch` must now returns a valid OpenAPI document
|
||||
- `parseOpenAPIV3` has been replaced by `parseOpenAPI`
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve OpenAPI rendering performances by caching markdown parsing
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve performances by highlighting code client-side if the code block is offscreen
|
||||
+11
-145
@@ -18,8 +18,7 @@ jobs:
|
||||
checks: write
|
||||
statuses: write
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
|
||||
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -39,7 +38,7 @@ jobs:
|
||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
- name: Build Next.js with next-on-pages
|
||||
run: bun run turbo build:cloudflare
|
||||
run: bun run build:cloudflare
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
@@ -75,159 +74,26 @@ jobs:
|
||||
-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 }}
|
||||
deploy-v2-vercel:
|
||||
name: Deploy v2 to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
statuses: write
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Sets env vars for production
|
||||
run: |
|
||||
echo "VERCEL_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
|
||||
if: startsWith(github.ref, 'refs/heads/main')
|
||||
- name: Sets env vars for preview
|
||||
run: |
|
||||
echo "VERCEL_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
- name: Pull Vercel Environment Information
|
||||
run: bun run vercel pull --yes --environment=$VERCEL_ENVIRONMENT --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Build Project Artifacts
|
||||
run: bun run vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
SENTRY_RELEASE: ${{ github.sha }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
DEPLOYMENT_URL=$(bun run vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
|
||||
echo "deployment-url=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
deploy-v2-cloudflare:
|
||||
name: Deploy v2 to Cloudflare Worker
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
statuses: write
|
||||
outputs:
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||
- name: Sets env vars for production
|
||||
run: |
|
||||
echo "SENTRY_ENVIRONMENT=production" >> $GITHUB_ENV
|
||||
echo "GITBOOK_ASSETS_PREFIX=https://static.gitbook.com" >> $GITHUB_ENV
|
||||
if: startsWith(github.ref, 'refs/heads/main')
|
||||
- name: Sets env vars for preview
|
||||
run: |
|
||||
echo "SENTRY_ENVIRONMENT=preview" >> $GITHUB_ENV
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
- name: Build worker
|
||||
run: bun run turbo build:v2:cloudflare
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
SENTRY_RELEASE: ${{ github.sha }}
|
||||
- id: deploy
|
||||
name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3.11.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: ./
|
||||
wranglerVersion: '3.82.0'
|
||||
command: versions upload --config ./packages/gitbook-v2/wrangler.toml
|
||||
- name: Outputs
|
||||
run: |
|
||||
echo "URL: ${{ steps.deploy.outputs.deployment-url }}"
|
||||
comment-deployments:
|
||||
runs-on: ubuntu-latest
|
||||
name: Comment Deployments
|
||||
if: always() && !startsWith(github.ref, 'refs/heads/main')
|
||||
needs:
|
||||
- deploy
|
||||
- deploy-v2-vercel
|
||||
- deploy-v2-cloudflare
|
||||
steps:
|
||||
|
||||
- name: Find GitHub Comment
|
||||
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: 'Summary of the deployments'
|
||||
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: |
|
||||
Summary of the deployments:
|
||||
|
||||
### Version 1 (production)
|
||||
|
||||
| Version | URL | Status |
|
||||
| --- | --- | --- |
|
||||
| Latest commit | [${{ needs.deploy.outputs.deployment-url }}](${{ needs.deploy.outputs.deployment-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
|
||||
| PR | [${{ needs.deploy.outputs.deployment-alias-url }}](${{ needs.deploy.outputs.deployment-alias-url }}) | ${{ needs.deploy.result == 'success' && '✅' || '❌' }} |
|
||||
|
||||
### Version 2 (experimental)
|
||||
|
||||
| Version | URL | Status |
|
||||
| --- | --- | --- |
|
||||
| Vercel | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}](${{ needs.deploy-v2-vercel.outputs.deployment-url }}) | ${{ needs.deploy-v2-vercel.result == 'success' && '✅' || '❌' }} |
|
||||
| Cloudflare | [${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}](${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}) | ${{ needs.deploy-v2-cloudflare.result == 'success' && '✅' || '❌' }} |
|
||||
|
||||
### Test content
|
||||
|
||||
| Site | v1 | v2 |
|
||||
| --- | --- | --- |
|
||||
| GitBook | [${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com](${{ needs.deploy.outputs.deployment-url }}/docs.gitbook.com) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/docs.gitbook.com) |
|
||||
| E2E | [${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy.outputs.deployment-url }}/gitbook.gitbook.io/test-gitbook-open) | [${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open](${{ needs.deploy-v2-vercel.outputs.deployment-url }}/static/url/gitbook.gitbook.io/test-gitbook-open) |
|
||||
**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
|
||||
@@ -245,7 +111,7 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
run: bun e2e
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy.outputs.deployment-url}}
|
||||
BASE_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -269,7 +135,7 @@ jobs:
|
||||
- name: Run pagespeed tests
|
||||
run: bun ./packages/gitbook/tests/pagespeed-testing.ts $DEPLOYMENT_URL
|
||||
env:
|
||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment-url}}
|
||||
DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}}
|
||||
PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }}
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.next
|
||||
.vercel
|
||||
.open-next
|
||||
|
||||
# Generated
|
||||
packages/emoji-codepoints/index.ts
|
||||
|
||||
@@ -35,10 +35,8 @@ To run a local version of this project, please follow these simple steps.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (Version: >=20.6)
|
||||
- Use nvm for easy Node management
|
||||
- Bun (Version: >=1.2.1)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.1
|
||||
- Node.js (Version: >=18.x)
|
||||
- Bun
|
||||
|
||||
### Set up
|
||||
|
||||
|
||||
+4
-7
@@ -2,10 +2,9 @@
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.3.4",
|
||||
"vercel": "^39.3.0"
|
||||
"turbo": "^2.1.2"
|
||||
},
|
||||
"packageManager": "bun@1.1.18",
|
||||
"overrides": {
|
||||
@@ -16,10 +15,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev --ui=stream",
|
||||
"dev:v2": "turbo run dev:v2 --ui=stream",
|
||||
"build": "turbo run build",
|
||||
"build:v2": "turbo run build:v2",
|
||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||
"build:cloudflare": "turbo run build:cloudflare",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint -- --fix",
|
||||
"typecheck": "turbo run typecheck",
|
||||
@@ -38,6 +35,6 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
|
||||
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
|
||||
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# cloudflare
|
||||
.open-next
|
||||
@@ -1,104 +0,0 @@
|
||||
# GitBook V2
|
||||
|
||||
This package contains the code for an experiment new version of the GitBook renderer.
|
||||
|
||||
The goals of this new version are:
|
||||
|
||||
- Optimize rendering to leverage Next.js ISR and static rendering
|
||||
- Leverage Node.js runtime and bigger instances to render bigger content
|
||||
- Evaluate Vercel and Cloudflare deployment options
|
||||
|
||||
## Deployment
|
||||
|
||||
#### Vercel
|
||||
|
||||
The new version is deployed automatically to Vercel using a Github Action on every push.
|
||||
|
||||
#### Cloudflare with Opennext-js
|
||||
|
||||
The new version is deployed automatically to Cloudflare using a Github Action on every push.
|
||||
|
||||
## Potential issues
|
||||
|
||||
- [ ] We can't use `dynamicIO` because our root layout needs to fetch the params (https://github.com/vercel/next.js/discussions/71927)
|
||||
- [ ] How to do `nonce` for static resources when we can't use `headers()` (dynamic API) (https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy)
|
||||
|
||||
## Structure
|
||||
|
||||
#### Middleware:
|
||||
|
||||
Goals:
|
||||
- Route between dynamic and static routes
|
||||
- If a token is in the cookies => dynamic route
|
||||
- If a token is in the url => dynamic route
|
||||
- Else => static route
|
||||
- High performances:
|
||||
- It should not rely on data fetching
|
||||
|
||||
#### URLs to support:
|
||||
|
||||
Content URLs:
|
||||
|
||||
- `docs.company.com/page`
|
||||
- Middleware: rewrite to `/static/url/docs.company.com/page`
|
||||
- Page component renders the page
|
||||
- `docs.company2.com/section/page`
|
||||
- Middleware: rewrite to `/static/url/docs.company2.com/section/page`
|
||||
- Page component renders the page
|
||||
- `docs.company3.com/section/sitespace/page`
|
||||
- Middleware: rewrite to `/static/url/docs.company3.com/section/sitespace/page`
|
||||
- Page component renders the page
|
||||
|
||||
Content URLs (adaptive content or visitor authentication):
|
||||
|
||||
- `docs.company.com/page`
|
||||
- Middleware:
|
||||
- If a token is in the cookies or url => dynamic route
|
||||
- Else => rewrite to `/dynamic/url/docs.company.com/page`
|
||||
- Page component renders the page
|
||||
- Read the token from the url query params or cookies
|
||||
|
||||
|
||||
PDF export URLs:
|
||||
|
||||
- `docs.company.com/page/~gitbook/pdf`
|
||||
- Middleware: rewrite to `/dynamic/pdf/docs.company.com/page`
|
||||
- Page component renders the page
|
||||
|
||||
Images resizing:
|
||||
|
||||
- `docs.company.com/~gitbook/image`
|
||||
- Middleware: rewrite to `/dynamic/image/docs.company.com`
|
||||
- Route handlers process the request
|
||||
|
||||
Preview URLs:
|
||||
|
||||
- `open.gitbook.com/sites/site_abc/page` (or `open.gitbook.com/~site/site_abc/page` to stay compatible?)
|
||||
- Middleware: no rewrite
|
||||
- Page component renders the page (dynamic?)
|
||||
- Read the token from the url query params
|
||||
- Read customizations override from the url query params
|
||||
|
||||
|
||||
Space PDF URLs:
|
||||
|
||||
- `open.gitbook.com/spaces/space_abc/pdf`
|
||||
- Middleware: no rewrite
|
||||
- Page component renders the page (dynamic?)
|
||||
- Read the token from the url query params
|
||||
- Read customizations override from the url query params
|
||||
|
||||
Development:
|
||||
|
||||
- `localhost:3000/sites/site_abc/page`
|
||||
|
||||
#### Routes:
|
||||
|
||||
- Content
|
||||
- [ ] `/static/url/[[...url]]/page.tsx`
|
||||
- [ ] `/dynamic/url/[[...url]]/page.tsx`
|
||||
- Images resizing
|
||||
- [ ] `/dynamic/image/[hostname]/route.ts`
|
||||
- PDF export
|
||||
- [ ] `/dynamic/pdf/[[...url]]/page.tsx`
|
||||
- [ ] `/spaces/[space]/pdf/page.tsx`
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,15 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
useCache: true,
|
||||
|
||||
// We can't use dynamicIO because it doesn't accept reading params in the root layout
|
||||
// dynamicIO: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,30 +0,0 @@
|
||||
// default open-next.config.ts file created by @opennextjs/cloudflare
|
||||
|
||||
import cache from '@opennextjs/cloudflare/kvCache';
|
||||
|
||||
const config = {
|
||||
default: {
|
||||
override: {
|
||||
wrapper: 'cloudflare-node',
|
||||
converter: 'edge',
|
||||
incrementalCache: async () => cache,
|
||||
tagCache: 'dummy',
|
||||
queue: 'dummy',
|
||||
},
|
||||
},
|
||||
|
||||
middleware: {
|
||||
external: true,
|
||||
override: {
|
||||
wrapper: 'cloudflare-edge',
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
},
|
||||
},
|
||||
|
||||
dangerous: {
|
||||
enableCacheInterception: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"next": "canary",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@gitbook/api": "^0.90.0",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^0.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:v2": "next",
|
||||
"build": "next build",
|
||||
"build:v2": "next build",
|
||||
"start": "next start",
|
||||
"build:v2:cloudflare": "opennextjs-cloudflare",
|
||||
"dev:v2:cloudflare": "wrangler dev --port 8771"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { SiteContentLayout } from '@v2/components/routes/SiteContentLayout';
|
||||
import { createDynamicSiteContext } from '@v2/lib/context';
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ url: string[] }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { url } = await params;
|
||||
const context = await createDynamicSiteContext(url);
|
||||
|
||||
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { SiteContentPage } from '@v2/components/routes/SiteContentPage';
|
||||
import { createDynamicSiteContext } from '@v2/lib/context';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
|
||||
const { url } = await params;
|
||||
const context = await createDynamicSiteContext(url);
|
||||
|
||||
return <SiteContentPage context={context} />;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { SiteContentLayout } from '@v2/components/routes/SiteContentLayout';
|
||||
import { getSiteCacheTag } from '@v2/lib/cache';
|
||||
import { createStaticSiteContext } from '@v2/lib/context';
|
||||
|
||||
export default async function RootLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ url: string[] }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
'use cache';
|
||||
|
||||
const { url } = await params;
|
||||
const context = await createStaticSiteContext(url);
|
||||
|
||||
cacheTag(getSiteCacheTag(context.siteId));
|
||||
|
||||
return <SiteContentLayout context={context}>{children}</SiteContentLayout>;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { createStaticSiteContext } from '@v2/lib/context';
|
||||
import { getSiteCacheTag } from '@v2/lib/cache';
|
||||
import { SiteContentPage } from '@v2/components/routes/SiteContentPage';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ url: string[] }> }) {
|
||||
'use cache';
|
||||
|
||||
const { url } = await params;
|
||||
const context = await createStaticSiteContext(url);
|
||||
|
||||
cacheTag(getSiteCacheTag(context.siteId));
|
||||
|
||||
return <SiteContentPage context={context} />;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { GitBookSiteSpaceContext } from '@v2/lib/context';
|
||||
import { Footer } from '@/components/Footer';
|
||||
|
||||
/**
|
||||
* Layout component to render the site content.
|
||||
*/
|
||||
export async function SiteContentLayout({
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
context: GitBookSiteSpaceContext;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [publishedSite, space] = await Promise.all([
|
||||
context.getPublishedContentSite(
|
||||
context.organizationId,
|
||||
context.siteId,
|
||||
),
|
||||
context.getSpaceById(context.spaceId, context.siteShareKey),
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<h1>{publishedSite.site.title}</h1>
|
||||
{children}
|
||||
<Footer
|
||||
space={space}
|
||||
customization={
|
||||
publishedSite.customizations.siteSpaces[context.siteSpaceId] ??
|
||||
publishedSite.customizations.site
|
||||
}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { GitBookSiteContext } from '@v2/lib/context';
|
||||
|
||||
export async function SiteContentPage({ context }: { context: GitBookSiteContext }) {
|
||||
const { api } = context;
|
||||
const { data } = await api.orgs.getPublishedContentSite(context.organizationId, context.siteId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Found site {data.site.id}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getSiteCacheTag(siteId: string) {
|
||||
return `sites/${siteId}`;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { GitBookAPI } from '@gitbook/api';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||
import { GitBookContext } from '@/lib/v2/context';
|
||||
|
||||
/**
|
||||
* Context when rendering a site.
|
||||
*/
|
||||
export interface GitBookSiteContext extends GitBookContext {
|
||||
/**
|
||||
* ID of the organization.
|
||||
*/
|
||||
organizationId: string;
|
||||
|
||||
/**
|
||||
* Site ID.
|
||||
*/
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context when rendering a space in a site.
|
||||
*/
|
||||
export interface GitBookSiteSpaceContext extends GitBookSiteContext {
|
||||
/**
|
||||
* ID of the space.
|
||||
*/
|
||||
spaceId: string;
|
||||
|
||||
/**
|
||||
* ID of the site section.
|
||||
*/
|
||||
siteSectionId: string | undefined;
|
||||
|
||||
/**
|
||||
* ID of the site space.
|
||||
*/
|
||||
siteSpaceId: string;
|
||||
|
||||
/**
|
||||
* Share key of the site.
|
||||
*/
|
||||
siteShareKey: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site context, when rendering a static page.
|
||||
*/
|
||||
export async function createStaticSiteContext(url: string[]): Promise<GitBookSiteSpaceContext> {
|
||||
const context = createStaticContext();
|
||||
return fetchSiteContext(url, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site context, when rendering a dynamic page.
|
||||
*/
|
||||
export async function createDynamicSiteContext(url: string[]): Promise<GitBookSiteSpaceContext> {
|
||||
const context = await createDynamicContext();
|
||||
return fetchSiteContext(url, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site for a given URL and a base context.
|
||||
*/
|
||||
async function fetchSiteContext(
|
||||
urlParts: string[],
|
||||
baseContext: GitBookContext,
|
||||
): Promise<GitBookSiteSpaceContext> {
|
||||
const { api } = baseContext;
|
||||
const url = getURLFromParams(urlParts);
|
||||
const { data } = await api.urls.getPublishedContentByUrl({
|
||||
url,
|
||||
});
|
||||
|
||||
if ('redirect' in data) {
|
||||
redirect(data.redirect);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseContext,
|
||||
api: new GitBookAPI({
|
||||
endpoint: api.endpoint,
|
||||
authToken: data.apiToken,
|
||||
userAgent: api.userAgent,
|
||||
}),
|
||||
siteId: data.site,
|
||||
organizationId: data.organization,
|
||||
siteSectionId: data.siteSection,
|
||||
siteSpaceId: data.siteSpace,
|
||||
spaceId: data.space,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the base context when rendering statically.
|
||||
*/
|
||||
function createStaticContext(): GitBookContext {
|
||||
const api = new GitBookAPI({
|
||||
authToken: GITBOOK_API_TOKEN,
|
||||
endpoint: GITBOOK_API_URL,
|
||||
userAgent: GITBOOK_USER_AGENT,
|
||||
});
|
||||
|
||||
return createContextFromClient(api);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the base context when rendering dynamically.
|
||||
* The context will depend on the request.
|
||||
*/
|
||||
async function createDynamicContext(): Promise<GitBookContext> {
|
||||
const headersSet = await headers();
|
||||
|
||||
const api = new GitBookAPI({
|
||||
authToken: headersSet.get('x-gitbook-token') ?? GITBOOK_API_TOKEN,
|
||||
endpoint: headersSet.get('x-gitbook-api') ?? GITBOOK_API_URL,
|
||||
userAgent: GITBOOK_USER_AGENT,
|
||||
});
|
||||
|
||||
return createContextFromClient(api);
|
||||
}
|
||||
|
||||
function createContextFromClient(api: GitBookAPI): GitBookContext {
|
||||
return {
|
||||
api,
|
||||
getLink: (pathname: string) => pathname,
|
||||
getPublishedContentSite: async (organizationId: string, siteId: string) => {
|
||||
const { data } = await api.orgs.getPublishedContentSite(organizationId, siteId);
|
||||
return data;
|
||||
},
|
||||
getRevisionFile: async (spaceId: string, revisionId: string, file: string) => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
getUserById: async (userId: string) => {
|
||||
const { data } = await api.users.getUserById(userId);
|
||||
return data;
|
||||
},
|
||||
getSpaceById: async (spaceId: string, shareKey: string | undefined) => {
|
||||
const { data } = await api.spaces.getSpaceById(spaceId, { shareKey });
|
||||
return data;
|
||||
},
|
||||
getCollection: async (collectionId: string) => {
|
||||
const { data } = await api.collections.getCollectionById(collectionId);
|
||||
return data;
|
||||
},
|
||||
getReusableContent: async (spaceId: string, revisionId: string, reusableContentId: string) => {
|
||||
const { data } = await api.spaces.getReusableContentInRevisionById(spaceId, revisionId, reusableContentId);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getURLFromParams(input: string[]) {
|
||||
const url = new URL('https://' + input.join('/'));
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Default GitBook API URL endpoint.
|
||||
*/
|
||||
export const GITBOOK_API_URL = process.env.GITBOOK_API_URL ?? 'https://api.gitbook.com';
|
||||
|
||||
/**
|
||||
* Default GitBook API token.
|
||||
* It can be use to avoid rate-limiting.
|
||||
*/
|
||||
export const GITBOOK_API_TOKEN = process.env.GITBOOK_API_TOKEN ?? null;
|
||||
|
||||
/**
|
||||
* User agent to use for API requests.
|
||||
*/
|
||||
export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT ?? 'GitBook-Open/2.0.0';
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
|
||||
};
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// No rewrite
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"module": "esnext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@v2/*": ["./src/*"],
|
||||
"@/*": ["../gitbook/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
main = ".open-next/worker.js"
|
||||
name = "gitbook-v2"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
||||
@@ -142,9 +142,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
@@ -324,9 +322,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'AI Search',
|
||||
url: '?q=What+is+GitBook%3F&ask=true',
|
||||
run: async (page) => {
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
|
||||
},
|
||||
screenshot: false,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gitbook/api": "^0.90.0",
|
||||
"@gitbook/api": "^0.88.1",
|
||||
"@gitbook/cache-do": "workspace:*",
|
||||
"@gitbook/emoji-codepoints": "workspace:*",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -40,9 +40,8 @@
|
||||
"jsontoxml": "^1.0.1",
|
||||
"katex": "^0.16.9",
|
||||
"mathjax": "^3.2.2",
|
||||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"memoizee": "^0.4.15",
|
||||
"next": "14.2.23",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
"object-hash": "^3.0.0",
|
||||
@@ -70,13 +69,11 @@
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^3.10.0",
|
||||
"@cloudflare/next-on-pages": "1.13.7",
|
||||
"vercel": "^39.3.0",
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
|
||||
@@ -109,7 +109,10 @@ export default async function Page(props: {
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
withPageFeedback={withPageFeedback}
|
||||
withPageFeedback={
|
||||
// Display the page feedback in the page footer if the aside is not visible
|
||||
withPageFeedback && !page.layout.outline
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { Heading, ListItem, Paragraph, Root, RootContent } from 'mdast';
|
||||
import { toMarkdown } from 'mdast-util-to-markdown';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { joinPath } from '@/lib/paths';
|
||||
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
|
||||
import { getIndexablePages } from '@/lib/sitemap';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Generate a llms.txt file for the site.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const pointer = await getSiteContentPointer();
|
||||
|
||||
const { structure: siteStructure, site } = await getPublishedContentSite({
|
||||
organizationId: pointer.organizationId,
|
||||
siteId: pointer.siteId,
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
});
|
||||
|
||||
if (!checkIsRootPointer(pointer, siteStructure)) {
|
||||
return new Response('llms.txt is only served from the root of the site', { status: 404 });
|
||||
}
|
||||
|
||||
const tree: Root = {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'heading',
|
||||
depth: 1,
|
||||
children: [{ type: 'text', value: site.title }],
|
||||
},
|
||||
...(await getNodesFromSiteStructure(siteStructure)),
|
||||
],
|
||||
};
|
||||
|
||||
return new Response(
|
||||
toMarkdown(tree, {
|
||||
bullet: '-',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site structure.
|
||||
*/
|
||||
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
|
||||
switch (siteStructure.type) {
|
||||
case 'sections':
|
||||
return getNodesFromSections(siteStructure.structure);
|
||||
case 'siteSpaces':
|
||||
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
|
||||
default:
|
||||
assertNever(siteStructure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site sections.
|
||||
*/
|
||||
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
|
||||
const all = await Promise.all(
|
||||
siteSections.map(async (siteSection): Promise<RootContent[]> => {
|
||||
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
|
||||
heading: false,
|
||||
});
|
||||
return [
|
||||
{
|
||||
type: 'heading',
|
||||
depth: 2,
|
||||
children: [{ type: 'text', value: siteSection.title }],
|
||||
},
|
||||
...siteSpaceNodes,
|
||||
];
|
||||
}),
|
||||
);
|
||||
return all.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MDAST nodes from site spaces.
|
||||
*/
|
||||
async function getNodesFromSiteSpaces(
|
||||
siteSpaces: SiteSpace[],
|
||||
options: {
|
||||
/**
|
||||
* Includes a heading for each site space.
|
||||
*/
|
||||
heading?: boolean;
|
||||
},
|
||||
): Promise<RootContent[]> {
|
||||
const all = await Promise.all(
|
||||
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
|
||||
const siteSpaceUrl = siteSpace.urls.published;
|
||||
if (!siteSpaceUrl) {
|
||||
return [];
|
||||
}
|
||||
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
|
||||
metadata: false,
|
||||
});
|
||||
const pages = getIndexablePages(rootPages);
|
||||
const listChildren = await Promise.all(
|
||||
pages.map(async ({ page }): Promise<ListItem> => {
|
||||
const url = await getAbsoluteHref(
|
||||
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
|
||||
true,
|
||||
);
|
||||
const children: Paragraph['children'] = [
|
||||
{
|
||||
type: 'link',
|
||||
url,
|
||||
children: [{ type: 'text', value: page.title }],
|
||||
},
|
||||
];
|
||||
if (page.description) {
|
||||
children.push({ type: 'text', value: `: ${page.description}` });
|
||||
}
|
||||
return {
|
||||
type: 'listItem',
|
||||
children: [{ type: 'paragraph', children }],
|
||||
};
|
||||
}),
|
||||
);
|
||||
const nodes: RootContent[] = [];
|
||||
if (options.heading) {
|
||||
nodes.push({
|
||||
type: 'heading',
|
||||
depth: 2,
|
||||
children: [{ type: 'text', value: siteSpace.title }],
|
||||
});
|
||||
}
|
||||
nodes.push({
|
||||
type: 'list',
|
||||
spread: false,
|
||||
children: listChildren,
|
||||
});
|
||||
return nodes;
|
||||
}),
|
||||
);
|
||||
return all.flat();
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function GET() {
|
||||
siteShareKey: pointer.siteShareKey,
|
||||
});
|
||||
|
||||
// This sitemap is only available at root (/sitemap.xml).
|
||||
if (!checkIsRootPointer(pointer, siteStructure)) {
|
||||
return new Response('Root sitemap is only served from the root of the site', {
|
||||
status: 404,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
SiteCustomizationSettings,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
@@ -147,11 +146,7 @@ export default async function PDFHTMLOutput(props: {
|
||||
limit={pdfParams.limit ?? DEFAULT_LIMIT}
|
||||
trademark={
|
||||
customization.trademark.enabled ? (
|
||||
<TrademarkLink
|
||||
space={space}
|
||||
customization={customization}
|
||||
placement={SiteInsightsTrademarkPlacement.Pdf}
|
||||
/>
|
||||
<TrademarkLink space={space} customization={customization} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { headers } from 'next/headers';
|
||||
import React from 'react';
|
||||
|
||||
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
|
||||
@@ -44,15 +43,8 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
export function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { content } = props;
|
||||
const mode = await headers().get('x-gitbook-mode');
|
||||
|
||||
if (mode === 'multi-id') {
|
||||
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content.changeRequestId) {
|
||||
return (
|
||||
<ChangeRequestToolbar
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SiteAds,
|
||||
SiteAdsStatus,
|
||||
SiteInsightsAdPlacement,
|
||||
SiteInsightsAd,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
} from '@gitbook/api';
|
||||
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';
|
||||
import { useHasBeenInViewport } from '../hooks/useHasBeenInViewport';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Zone ID provided by BuySellAds for the preview.
|
||||
@@ -37,34 +28,50 @@ export function Ad({
|
||||
}: {
|
||||
zoneId: string | null;
|
||||
spaceId: string;
|
||||
placement: SiteInsightsAdPlacement;
|
||||
placement: string;
|
||||
ignore: boolean;
|
||||
style?: ClassValue;
|
||||
siteAdsStatus?: SiteAds['status'];
|
||||
mode?: 'classic' | 'auto' | 'cover';
|
||||
}) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [ad, setAd] = React.useState<
|
||||
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
|
||||
>(undefined);
|
||||
const trackEvent = useTrackEvent();
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
|
||||
|
||||
// Track display of the ad
|
||||
// Observe the container visibility
|
||||
React.useEffect(() => {
|
||||
if (ad?.insightsAd) {
|
||||
trackEvent({
|
||||
type: 'ad_display',
|
||||
ad: ad.insightsAd,
|
||||
});
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
}, [ad, trackEvent]);
|
||||
|
||||
const hasBeenInViewport = useHasBeenInViewport(containerRef, { threshold: 0.1 });
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
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 (!hasBeenInViewport) {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,13 +115,13 @@ export function Ad({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasBeenInViewport, zoneId, ignore, placement, mode, siteAdsStatus]);
|
||||
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
|
||||
{ad ? (
|
||||
<>
|
||||
{ad.children}
|
||||
{ad}
|
||||
<AdSponsoredLink spaceId={spaceId} />
|
||||
</>
|
||||
) : null}
|
||||
@@ -125,7 +132,6 @@ export function Ad({
|
||||
function AdSponsoredLink(props: { spaceId: string }) {
|
||||
const { spaceId } = props;
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
|
||||
const viaUrl = new URL('https://www.gitbook.com');
|
||||
viaUrl.searchParams.set('utm_source', 'content');
|
||||
@@ -143,17 +149,9 @@ function AdSponsoredLink(props: { spaceId: string }) {
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={viaUrl.toString()}
|
||||
className={tcls('hover:underline')}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement: SiteInsightsTrademarkPlacement.Ad,
|
||||
}}
|
||||
>
|
||||
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
|
||||
{t(language, 'sponsored_via_gitbook')}
|
||||
</Link>
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
import { SiteInsightsAd } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdItem } from './types';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Classic rendering for an ad.
|
||||
*/
|
||||
export async function AdClassicRendering({
|
||||
ad,
|
||||
insightsAd,
|
||||
}: {
|
||||
ad: AdItem;
|
||||
insightsAd: SiteInsightsAd | null;
|
||||
}) {
|
||||
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
|
||||
const smallImgSrc =
|
||||
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
|
||||
const logoSrc =
|
||||
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
|
||||
return (
|
||||
<Link
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
insights={
|
||||
insightsAd
|
||||
? {
|
||||
type: 'ad_click',
|
||||
ad: insightsAd,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
<a
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
@@ -47,6 +29,8 @@ export async function AdClassicRendering({
|
||||
'p-4',
|
||||
)}
|
||||
href={ad.statlink}
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
>
|
||||
{smallImgSrc && 'smallImage' in ad ? (
|
||||
<div>
|
||||
@@ -63,6 +47,6 @@ export async function AdClassicRendering({
|
||||
<div className={tcls('flex', 'flex-col')}>
|
||||
<div className={tcls('text-xs')}>{ad.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SiteInsightsAd } from '@gitbook/api';
|
||||
import * as React from 'react';
|
||||
|
||||
import { hexToRgba } from '@/lib/colors';
|
||||
@@ -6,32 +5,15 @@ import { getResizedImageURL } from '@/lib/images';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AdCover } from './types';
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Cover rendering for an ad.
|
||||
*/
|
||||
export async function AdCoverRendering({
|
||||
ad,
|
||||
insightsAd,
|
||||
}: {
|
||||
ad: AdCover;
|
||||
insightsAd: SiteInsightsAd | null;
|
||||
}) {
|
||||
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
|
||||
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
|
||||
|
||||
return (
|
||||
<Link
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
insights={
|
||||
insightsAd
|
||||
? {
|
||||
type: 'ad_click',
|
||||
ad: insightsAd,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
<a
|
||||
className={tcls(
|
||||
'group/ad',
|
||||
'relative',
|
||||
@@ -51,6 +33,8 @@ export async function AdCoverRendering({
|
||||
)}
|
||||
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
|
||||
href={ad.statlink}
|
||||
rel="sponsored noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -122,6 +106,6 @@ export async function AdCoverRendering({
|
||||
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use server';
|
||||
|
||||
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { AdClassicRendering } from './AdClassicRendering';
|
||||
@@ -21,7 +20,7 @@ interface FetchLiveAdOptions {
|
||||
/** Mode to render the Ad */
|
||||
mode: 'classic' | 'auto' | 'cover';
|
||||
/** Name of the placement for the ad */
|
||||
placement: SiteInsightsAdPlacement;
|
||||
placement: string;
|
||||
/** If true, we'll not track it as an impression */
|
||||
ignore: boolean;
|
||||
}
|
||||
@@ -48,28 +47,16 @@ export async function renderAd(options: FetchAdOptions) {
|
||||
|
||||
const { ad } = result;
|
||||
|
||||
const insightsAd: SiteInsightsAd | null =
|
||||
options.source === 'live'
|
||||
? {
|
||||
placement: options.placement,
|
||||
zoneId: options.zoneId,
|
||||
domain: 'company' in ad ? ad.company : '',
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
children: (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} insightsAd={insightsAd} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} insightsAd={insightsAd} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
),
|
||||
insightsAd,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{mode === 'classic' || !('callToAction' in ad) ? (
|
||||
<AdClassicRendering ad={ad} />
|
||||
) : (
|
||||
<AdCoverRendering ad={ad} />
|
||||
)}
|
||||
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAd({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Blocks } from '../Blocks';
|
||||
import { InlineProps } from '../Inline';
|
||||
import { Inlines } from '../Inlines';
|
||||
|
||||
export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
|
||||
export async function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
|
||||
const { inline, context, document, children } = props;
|
||||
|
||||
const fragment = getNodeFragmentByType(inline, 'annotation-body');
|
||||
|
||||
@@ -35,11 +35,8 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
|
||||
title={resolved.text}
|
||||
style={style}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport';
|
||||
|
||||
import type { HighlightLine, RenderedInline } from './highlight';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { CodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a code-block client-side by loading the highlighter asynchronously.
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block, []));
|
||||
|
||||
// Preload the highlighter when the block is mounted.
|
||||
useEffect(() => {
|
||||
import('./highlight').then(({ preloadHighlight }) => preloadHighlight(block));
|
||||
}, [block]);
|
||||
|
||||
// Check if the block is in the viewport to start highlighting it.
|
||||
const hasBeenInViewport = useHasBeenInViewport(blockRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
// Highlight the block when it's in the viewport.
|
||||
useEffect(() => {
|
||||
if (hasBeenInViewport) {
|
||||
let canceled = false;
|
||||
import('./highlight').then(({ highlight }) => {
|
||||
// We use requestIdleCallback to avoid blocking the main thread
|
||||
// when scrolling.
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(() => highlight(block, inlines).then(setLines));
|
||||
} else {
|
||||
highlight(block, inlines).then(setLines);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}
|
||||
}, [hasBeenInViewport, block, inlines]);
|
||||
|
||||
return <CodeBlockRenderer ref={blockRef} block={block} style={style} lines={lines} />;
|
||||
}
|
||||
@@ -1,38 +1,291 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
|
||||
|
||||
import { getNodeFragmentByType } from '@/lib/document';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import { highlight, HighlightLine, HighlightToken, plainHighlighting } from './highlight';
|
||||
import { BlockProps } from '../Block';
|
||||
import { ClientCodeBlock } from './ClientCodeBlock';
|
||||
import { getInlines, RenderedInline } from './highlight';
|
||||
import { Blocks } from '../Blocks';
|
||||
import { DocumentContext } from '../DocumentView';
|
||||
import { Inline } from '../Inline';
|
||||
|
||||
import './theme.css';
|
||||
|
||||
/**
|
||||
* Render a code block, can be client-side or server-side.
|
||||
* Render an entire code-block. The syntax highlighting is done server-side.
|
||||
*/
|
||||
export function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, context } = props;
|
||||
const inlines = getInlines(block);
|
||||
const richInlines: RenderedInline[] = inlines.map((inline, index) => {
|
||||
const body = (() => {
|
||||
const fragment = getNodeFragmentByType(inline.inline, 'annotation-body');
|
||||
if (!fragment) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Blocks
|
||||
key={index}
|
||||
document={document}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style="space-y-4"
|
||||
/>
|
||||
);
|
||||
})();
|
||||
const lines = await highlight(block);
|
||||
|
||||
return { inline, body };
|
||||
});
|
||||
const id = block.key!;
|
||||
|
||||
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
const withLineNumbers = !!block.data.lineNumbers && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
const titleRoundingStyle = [
|
||||
'rounded-md',
|
||||
'straight-corners:rounded-sm',
|
||||
title ? 'rounded-ss-none' : null,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={tcls('group/codeblock', 'grid', 'grid-flow-col', style)}>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-start',
|
||||
'[grid-area:1/1]',
|
||||
'text-sm',
|
||||
'gap-2',
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'text-xs',
|
||||
'tracking-wide',
|
||||
'text-dark/7',
|
||||
'leading-none',
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-light-2',
|
||||
'rounded-t',
|
||||
'straight-corners:rounded-t-s',
|
||||
'px-3',
|
||||
'py-2',
|
||||
'dark:bg-dark-2',
|
||||
'dark:text-light/7',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
style={[
|
||||
'group-hover/codeblock:opacity-[1]',
|
||||
'transition-opacity',
|
||||
'duration-75',
|
||||
'opacity-0',
|
||||
'text-xs',
|
||||
'[grid-area:2/1]',
|
||||
'z-[2]',
|
||||
'justify-self-end',
|
||||
'backdrop-blur-md',
|
||||
'leading-none',
|
||||
'self-start',
|
||||
'ring-1',
|
||||
'ring-dark/2',
|
||||
'text-dark/7',
|
||||
'bg-transparent',
|
||||
'rounded-md',
|
||||
'mr-2',
|
||||
'mt-2',
|
||||
'p-1',
|
||||
'hover:ring-dark/3',
|
||||
'dark:ring-light/2',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:ring-light/3',
|
||||
]}
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'[grid-area:2/1]',
|
||||
'relative',
|
||||
'overflow-auto',
|
||||
'bg-light-2',
|
||||
'dark:bg-dark-2',
|
||||
'border-light-4',
|
||||
'dark:border-dark-4',
|
||||
'hide-scroll',
|
||||
titleRoundingStyle,
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
className={tcls(
|
||||
'min-w-full',
|
||||
'inline-grid',
|
||||
'[grid-template-columns:auto_1fr]',
|
||||
'py-2',
|
||||
'px-2',
|
||||
'[counter-reset:line]',
|
||||
withWrap ? 'whitespace-pre-wrap' : '',
|
||||
)}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
block={block}
|
||||
document={document}
|
||||
key={index}
|
||||
line={line}
|
||||
lineIndex={index + 1}
|
||||
isLast={index === lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
withWrap={withWrap}
|
||||
context={context}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightLine(props: {
|
||||
block: DocumentBlockCode;
|
||||
document: JSONDocument;
|
||||
line: HighlightLine;
|
||||
lineIndex: number;
|
||||
isLast: boolean;
|
||||
withLineNumbers: boolean;
|
||||
withWrap: boolean;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { block, document, line, isLast, withLineNumbers, context } = props;
|
||||
return (
|
||||
<span
|
||||
className={tcls(
|
||||
'grid',
|
||||
'[grid-template-columns:subgrid]',
|
||||
'col-span-2',
|
||||
'relative',
|
||||
'ring-1',
|
||||
'ring-transparent',
|
||||
'hover:ring-dark-4/5',
|
||||
'hover:z-[1]',
|
||||
'dark:hover:ring-light-4/4',
|
||||
'rounded',
|
||||
//first child
|
||||
'[&.highlighted:first-child]:rounded-t-md',
|
||||
'[&.highlighted:first-child>*]:mt-1',
|
||||
//last child
|
||||
'[&.highlighted:last-child]:rounded-b-md',
|
||||
'[&.highlighted:last-child>*]:mb-1',
|
||||
//is only child, dont hover effect line
|
||||
'[&:only-child]:hover:ring-transparent',
|
||||
//select all highlighted
|
||||
'[&.highlighted]:rounded-none',
|
||||
//select first in group
|
||||
'[&:not(.highlighted)_+_.highlighted]:rounded-t-md',
|
||||
'[&:not(.highlighted)_+_.highlighted>*]:mt-1',
|
||||
//select last in group
|
||||
'[&.highlighted:has(+:not(.highlighted))]:rounded-b-md',
|
||||
'[&.highlighted:has(+:not(.highlighted))>*]:mb-1',
|
||||
//select if highlight is singular in group
|
||||
'[&:not(.highlighted)_+_.highlighted:has(+:not(.highlighted))]:rounded-md',
|
||||
|
||||
line.highlighted ? ['highlighted', 'bg-light-3', 'dark:bg-dark-3'] : null,
|
||||
)}
|
||||
>
|
||||
{withLineNumbers ? (
|
||||
<span
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
'text-right',
|
||||
'pr-3.5',
|
||||
'rounded-l',
|
||||
'pl-2',
|
||||
'sticky',
|
||||
'left-[-3px]',
|
||||
'bg-gradient-to-r',
|
||||
'from-80%',
|
||||
'from-light-2',
|
||||
'to-transparent',
|
||||
'dark:from-dark-2',
|
||||
'dark:to-transparent',
|
||||
withLineNumbers
|
||||
? [
|
||||
'before:text-dark/5',
|
||||
'before:content-[counter(line)]',
|
||||
'[counter-increment:line]',
|
||||
'dark:before:text-light/4',
|
||||
|
||||
line.highlighted
|
||||
? [
|
||||
'before:text-dark/6',
|
||||
'dark:before:text-light/8',
|
||||
'bg-gradient-to-r',
|
||||
'from-80%',
|
||||
'from-light-3',
|
||||
'to-transparent',
|
||||
'dark:from-dark-3',
|
||||
'dark:to-transparent',
|
||||
]
|
||||
: null,
|
||||
]
|
||||
: [],
|
||||
)}
|
||||
></span>
|
||||
) : null}
|
||||
|
||||
<span className={tcls('ml-3', 'block', 'text-sm')}>
|
||||
<CodeHighlightTokens tokens={line.tokens} document={document} context={context} />
|
||||
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
|
||||
<span className="ew">{'\u200B'}</span>
|
||||
) : (
|
||||
'\n'
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightTokens(props: {
|
||||
tokens: HighlightToken[];
|
||||
document: JSONDocument;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { tokens, document, context } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tokens.map((token, index) => (
|
||||
<CodeHighlightToken
|
||||
key={index}
|
||||
token={token}
|
||||
document={document}
|
||||
context={context}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightToken(props: {
|
||||
token: HighlightToken;
|
||||
document: JSONDocument;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { token, document, context } = props;
|
||||
|
||||
if (token.type === 'inline') {
|
||||
return (
|
||||
<Inline
|
||||
inline={token.inline}
|
||||
document={document}
|
||||
context={context}
|
||||
ancestorInlines={[]}
|
||||
>
|
||||
<CodeHighlightTokens
|
||||
tokens={token.children}
|
||||
document={document}
|
||||
context={context}
|
||||
/>
|
||||
</Inline>
|
||||
);
|
||||
}
|
||||
|
||||
if (token.type === 'plain') {
|
||||
return <>{token.content}</>;
|
||||
}
|
||||
|
||||
if (!token.token.color) {
|
||||
return <>{token.token.content}</>;
|
||||
}
|
||||
|
||||
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
.highlight-line {
|
||||
@apply grid [grid-template-columns:subgrid] col-span-2 relative ring-1 ring-transparent hover:ring-dark-4/5 hover:z-[1] dark:hover:ring-light-4/4 rounded;
|
||||
@apply only:hover:ring-transparent;
|
||||
@apply [counter-increment:line];
|
||||
|
||||
&.highlighted {
|
||||
@apply bg-light-3 dark:bg-dark-3;
|
||||
@apply first:rounded-t-md *:first:mt-1;
|
||||
@apply last:rounded-b-md *:last:mb-1;
|
||||
@apply rounded-none;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted {
|
||||
@apply rounded-t-md *:mt-1;
|
||||
}
|
||||
|
||||
&.highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-b-md *:mb-1;
|
||||
}
|
||||
|
||||
&:not(.highlighted) + .highlighted:has(+ :not(.highlighted)) {
|
||||
@apply rounded-md;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-line-number {
|
||||
@apply text-sm text-right pr-3.5 rounded-l pl-2 sticky left-[-3px] bg-gradient-to-r from-80% from-light-2 to-transparent dark:from-dark-2 dark:to-transparent;
|
||||
@apply before:text-dark/5 before:content-[counter(line)] dark:before:text-light/4;
|
||||
|
||||
.highlight-line.highlighted > & {
|
||||
@apply before:text-dark/6 dark:before:text-light/8 bg-gradient-to-r from-80% from-light-3 to-transparent dark:from-dark-3 dark:to-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-line-content {
|
||||
@apply ml-3 block text-sm;
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { forwardRef, useId } from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import type { HighlightLine, HighlightToken } from './highlight';
|
||||
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
|
||||
import type { BlockProps } from '../Block';
|
||||
|
||||
import './theme.css';
|
||||
import './CodeBlockRenderer.css';
|
||||
|
||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
lines: HighlightLine[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The logic of rendering a code block from lines.
|
||||
*/
|
||||
export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
props: CodeBlockRendererProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const { block, style, lines } = props;
|
||||
|
||||
const id = useId();
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={tcls('group/codeblock grid grid-flow-col', style)}>
|
||||
<div className="flex items-center justify-start [grid-area:1/1] text-sm gap-2">
|
||||
{title ? (
|
||||
<div className="text-xs tracking-wide text-dark/7 leading-none inline-flex items-center justify-center bg-light-2 rounded-t straight-corners:rounded-t-s px-3 py-2 dark:bg-dark-2 dark:text-light/7">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
style="group-hover/codeblock:opacity-[1] transition-opacity duration-75 opacity-0 text-xs [grid-area:2/1] z-[2] justify-self-end backdrop-blur-md leading-none self-start ring-1 ring-dark/2 text-dark/7 bg-transparent rounded-md mr-2 mt-2 p-1 hover:ring-dark/3 dark:ring-light/2 dark:text-light/7 dark:hover:ring-light/3"
|
||||
/>
|
||||
<pre
|
||||
className={tcls(
|
||||
'[grid-area:2/1] relative overflow-auto bg-light-2 dark:bg-dark-2 dark:border-dark-4 hide-scroll',
|
||||
'rounded-md straight-corners:rounded-sm',
|
||||
title && 'rounded-ss-none',
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
className={tcls(
|
||||
'min-w-full inline-grid grid-cols-[auto_1fr] p-2 [count-reset:line]',
|
||||
withWrap && 'whitespace-pre-wrap',
|
||||
)}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<CodeHighlightLine
|
||||
key={index}
|
||||
line={line}
|
||||
isLast={index === lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function CodeHighlightLine(props: {
|
||||
line: HighlightLine;
|
||||
isLast: boolean;
|
||||
withLineNumbers: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers } = props;
|
||||
return (
|
||||
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
|
||||
{withLineNumbers && <span className="highlight-line-number" />}
|
||||
<span className="highlight-line-content">
|
||||
<CodeHighlightTokens tokens={line.tokens} />
|
||||
{!isLast && '\n'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightTokens(props: { tokens: HighlightToken[] }) {
|
||||
const { tokens } = props;
|
||||
return tokens.map((token, index) => <CodeHighlightToken key={index} token={token} />);
|
||||
}
|
||||
|
||||
function CodeHighlightToken(props: { token: HighlightToken }) {
|
||||
const { token } = props;
|
||||
|
||||
switch (token.type) {
|
||||
case 'annotation': {
|
||||
return (
|
||||
<AnnotationPopover body={token.body}>
|
||||
<CodeHighlightTokens tokens={token.children} />
|
||||
</AnnotationPopover>
|
||||
);
|
||||
}
|
||||
case 'plain': {
|
||||
return token.content;
|
||||
}
|
||||
case 'shiki': {
|
||||
if (!token.token.color) {
|
||||
return token.token.content;
|
||||
}
|
||||
|
||||
return <span style={{ color: token.token.color }}>{token.token.content}</span>;
|
||||
}
|
||||
default:
|
||||
assertNever(token);
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
|
||||
}}
|
||||
block={block}
|
||||
ancestorBlocks={[]}
|
||||
// We optimize perf by default
|
||||
isEstimatedOffscreen
|
||||
isEstimatedOffscreen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { it, expect } from 'bun:test';
|
||||
|
||||
import { getInlines, highlight, RenderedInline } from './highlight';
|
||||
|
||||
async function highlightWithInlines(block: DocumentBlockCode) {
|
||||
const inlines: RenderedInline[] = getInlines(block).map((inline) => ({
|
||||
inline,
|
||||
body: null,
|
||||
}));
|
||||
return highlight(block, inlines);
|
||||
}
|
||||
import { highlight } from './highlight';
|
||||
|
||||
it('should parse plain code', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {},
|
||||
@@ -47,35 +38,32 @@ it('should parse plain code', async () => {
|
||||
it('should parse different code in parallel', async () => {
|
||||
await Promise.all(
|
||||
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
|
||||
highlight(
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
syntax: syntax,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
syntax: syntax,
|
||||
},
|
||||
[],
|
||||
),
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a multilines plain code', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {},
|
||||
@@ -150,7 +138,7 @@ it('should parse a multilines plain code', async () => {
|
||||
});
|
||||
|
||||
it('should parse code with an inline on a single line', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
@@ -204,8 +192,10 @@ it('should parse code with an inline on a single line', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -239,7 +229,7 @@ it('should parse code with an inline on a single line', async () => {
|
||||
});
|
||||
|
||||
it('should parse code with an inline on a multiple line', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
@@ -320,8 +310,10 @@ it('should parse code with an inline on a multiple line', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -355,8 +347,10 @@ it('should parse code with an inline on a multiple line', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -390,7 +384,7 @@ it('should parse code with an inline on a multiple line', async () => {
|
||||
});
|
||||
|
||||
it('should support code token finishing before the end of the annotation', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
isVoid: false,
|
||||
@@ -476,8 +470,10 @@ it('should support code token finishing before the end of the annotation', async
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
type: 'inline',
|
||||
inline: {
|
||||
type: 'annotation',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -511,7 +507,7 @@ it('should support code token finishing before the end of the annotation', async
|
||||
});
|
||||
|
||||
it('should support multiple code tokens in an annotation', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
isVoid: false,
|
||||
@@ -619,8 +615,11 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'annotation',
|
||||
body: null,
|
||||
type: 'inline',
|
||||
inline: {
|
||||
object: 'inline',
|
||||
type: 'annotation',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'shiki',
|
||||
@@ -654,7 +653,7 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
});
|
||||
|
||||
it('should handle \\r', async () => {
|
||||
const tokens = await highlightWithInlines({
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
|
||||
@@ -3,12 +3,14 @@ import {
|
||||
createdBundledHighlighter,
|
||||
ThemedToken,
|
||||
createCssVariablesTheme,
|
||||
createSingletonShorthands,
|
||||
HighlighterGeneric,
|
||||
} from 'shiki/core';
|
||||
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
|
||||
import { BundledLanguage, bundledLanguages } from 'shiki/langs';
|
||||
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
import { asyncMutexFunction, singleton } from '@/lib/async';
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { trace } from '@/lib/tracing';
|
||||
|
||||
export type HighlightLine = {
|
||||
highlighted: boolean;
|
||||
@@ -18,61 +20,34 @@ export type HighlightLine = {
|
||||
export type HighlightToken =
|
||||
| { type: 'plain'; content: string }
|
||||
| { type: 'shiki'; token: ThemedToken }
|
||||
| { type: 'annotation'; body: React.ReactNode; children: HighlightToken[] };
|
||||
| { type: 'inline'; inline: DocumentInlineAnnotation; children: HighlightToken[] };
|
||||
|
||||
export type InlineIndexed = { inline: any; start: number; end: number };
|
||||
type InlineIndexed = { inline: any; start: number; end: number };
|
||||
|
||||
type PositionedToken = ThemedToken & { start: number; end: number };
|
||||
|
||||
export type RenderedInline = {
|
||||
inline: InlineIndexed;
|
||||
body: React.ReactNode;
|
||||
};
|
||||
|
||||
const theme = createCssVariablesTheme();
|
||||
|
||||
const highlighter = createSingletonShorthands(
|
||||
createdBundledHighlighter<any, any>({
|
||||
langs: bundledLanguages,
|
||||
themes: {},
|
||||
engine: () =>
|
||||
createJavaScriptRegexEngine({
|
||||
forgiving: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Preload the highlighter for a code block.
|
||||
*/
|
||||
export async function preloadHighlight(block: DocumentBlockCode) {
|
||||
const langName = getBlockLang(block);
|
||||
if (langName) {
|
||||
await highlighter.getSingletonHighlighter({
|
||||
langs: [langName],
|
||||
themes: [theme],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a code block while preserving inline elements.
|
||||
*/
|
||||
export async function highlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[],
|
||||
): Promise<HighlightLine[]> {
|
||||
const langName = getBlockLang(block);
|
||||
export async function highlight(block: DocumentBlockCode): Promise<HighlightLine[]> {
|
||||
const langName = block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
|
||||
if (!langName) {
|
||||
// Language not found, fallback to plain highlighting
|
||||
return plainHighlight(block, inlines);
|
||||
return plainHighlighting(block);
|
||||
}
|
||||
|
||||
const code = getPlainCodeBlock(block);
|
||||
const inlines: InlineIndexed[] = [];
|
||||
const code = getPlainCodeBlock(block, inlines);
|
||||
|
||||
const lines = await highlighter.codeToTokensBase(code, {
|
||||
inlines.sort((a, b) => {
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
||||
const highlighter = await loadHighlighter();
|
||||
await loadHighlighterLanguage(highlighter, langName);
|
||||
|
||||
const lines = highlighter.codeToTokensBase(code, {
|
||||
lang: langName,
|
||||
theme,
|
||||
tokenizeMaxLineLength: 400,
|
||||
});
|
||||
|
||||
@@ -98,19 +73,12 @@ export async function highlight(
|
||||
currentIndex += 1; // for the \n
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock.data.highlighted),
|
||||
highlighted: !!lineBlock.data.highlighted,
|
||||
tokens: result,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language of a code block.
|
||||
*/
|
||||
function getBlockLang(block: DocumentBlockCode): string | null {
|
||||
return block.data.syntax ? getLanguageForSyntax(block.data.syntax) : null;
|
||||
}
|
||||
|
||||
const syntaxAliases: Record<string, BundledLanguage> = {
|
||||
// "Parser" language does not exist in Shiki, but it's used in GitBook
|
||||
// The closest language is "Blade"
|
||||
@@ -148,18 +116,43 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInlines(block: DocumentBlockCode) {
|
||||
const inlines: InlineIndexed[] = [];
|
||||
getPlainCodeBlock(block, inlines);
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
*/
|
||||
export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = [];
|
||||
|
||||
inlines.sort((a, b) => a.start - b.start);
|
||||
for (const node of lineBlock.nodes) {
|
||||
if (node.object === 'text') {
|
||||
tokens.push({
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
type: 'inline',
|
||||
inline: node,
|
||||
children: [
|
||||
{
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return inlines;
|
||||
return {
|
||||
highlighted: !!lineBlock.data.highlighted,
|
||||
tokens,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchTokenAndInlines(
|
||||
eat: () => PositionedToken | null,
|
||||
allInlines: RenderedInline[],
|
||||
allInlines: InlineIndexed[],
|
||||
): HighlightToken[] {
|
||||
const initialToken = eat();
|
||||
if (!initialToken) {
|
||||
@@ -167,7 +160,7 @@ function matchTokenAndInlines(
|
||||
}
|
||||
|
||||
const inlines = allInlines.filter(
|
||||
({ inline }) => inline.start >= initialToken.start && inline.start < initialToken.end,
|
||||
(inline) => inline.start >= initialToken.start && inline.start < initialToken.end,
|
||||
);
|
||||
let token = initialToken;
|
||||
const result: HighlightToken[] = [];
|
||||
@@ -183,7 +176,7 @@ function matchTokenAndInlines(
|
||||
return;
|
||||
}
|
||||
|
||||
const [before, afterBefore] = splitPositionedTokenAt(token, inline.inline.start);
|
||||
const [before, afterBefore] = splitPositionedTokenAt(token, inline.start);
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'shiki',
|
||||
@@ -198,7 +191,7 @@ function matchTokenAndInlines(
|
||||
const children: HighlightToken[] = [];
|
||||
|
||||
// If shiki token finished before the end of the annotation or the annotation contains multiple tokens
|
||||
while (token.end < inline.inline.end) {
|
||||
while (token.end < inline.end) {
|
||||
children.push({
|
||||
type: 'shiki',
|
||||
token: token,
|
||||
@@ -211,7 +204,7 @@ function matchTokenAndInlines(
|
||||
token = next;
|
||||
}
|
||||
|
||||
const [inside, after] = splitPositionedTokenAt(token, inline.inline.end);
|
||||
const [inside, after] = splitPositionedTokenAt(token, inline.end);
|
||||
if (!inside) {
|
||||
throw new Error(`expect inside to not be empty`);
|
||||
}
|
||||
@@ -222,8 +215,8 @@ function matchTokenAndInlines(
|
||||
});
|
||||
|
||||
result.push({
|
||||
type: 'annotation',
|
||||
body: inline.body,
|
||||
type: 'inline',
|
||||
inline: inline.inline,
|
||||
children,
|
||||
});
|
||||
|
||||
@@ -237,11 +230,11 @@ function matchTokenAndInlines(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]): string {
|
||||
function getPlainCodeBlock(code: DocumentBlockCode, inlines: InlineIndexed[]): string {
|
||||
let content = '';
|
||||
|
||||
code.nodes.forEach((node, index) => {
|
||||
const lineContent = getPlainCodeBlockLine(node, content.length, inlines);
|
||||
const lineContent = getPlainCodeBlockLine(node, inlines, content.length);
|
||||
content += lineContent;
|
||||
|
||||
if (index < code.nodes.length - 1) {
|
||||
@@ -254,8 +247,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
|
||||
|
||||
function getPlainCodeBlockLine(
|
||||
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
|
||||
inlines: InlineIndexed[],
|
||||
index: number,
|
||||
inlines?: InlineIndexed[],
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
@@ -264,16 +257,14 @@ function getPlainCodeBlockLine(
|
||||
content += cleanupLine(node.leaves.map((leaf) => leaf.text).join(''));
|
||||
} else {
|
||||
const start = index + content.length;
|
||||
content += getPlainCodeBlockLine(node, index + content.length, inlines);
|
||||
content += getPlainCodeBlockLine(node, inlines, index + content.length);
|
||||
const end = index + content.length;
|
||||
|
||||
if (inlines) {
|
||||
inlines.push({
|
||||
inline: node,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
inlines.push({
|
||||
inline: node,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,3 +316,43 @@ function isEmptyPositionedToken(token: PositionedToken): boolean {
|
||||
function cleanupLine(line: string): string {
|
||||
return line.replace(/\r/g, '');
|
||||
}
|
||||
|
||||
const createHighlighter = createdBundledHighlighter<any, any>({
|
||||
langs: bundledLanguages,
|
||||
themes: {},
|
||||
engine: () =>
|
||||
createJavaScriptRegexEngine({
|
||||
forgiving: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Load the highlighter, only once, and reuse it.
|
||||
* It makes sure to handle concurrent calls.
|
||||
*/
|
||||
const loadHighlighter = singleton(async () => {
|
||||
return await trace('highlighting.loadHighlighter', async () => {
|
||||
const highlighter = await createHighlighter({
|
||||
themes: [createCssVariablesTheme()],
|
||||
langs: [],
|
||||
});
|
||||
return highlighter;
|
||||
});
|
||||
});
|
||||
|
||||
const loadLanguagesMutex = asyncMutexFunction();
|
||||
async function loadHighlighterLanguage(
|
||||
highlighter: HighlighterGeneric<any, any>,
|
||||
lang: keyof typeof bundledLanguages,
|
||||
) {
|
||||
await loadLanguagesMutex.runBlocking(async () => {
|
||||
if (highlighter.getLoadedLanguages().includes(lang)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await trace(
|
||||
`highlighting.loadLanguage(${lang})`,
|
||||
async () => await highlighter.loadLanguage(lang),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { getNodeText } from '@/lib/document';
|
||||
|
||||
import type { HighlightLine, HighlightToken, RenderedInline } from './highlight';
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
*/
|
||||
export function plainHighlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[],
|
||||
): HighlightLine[] {
|
||||
const inlinesCopy = Array.from(inlines);
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = lineBlock.nodes.map((node) => {
|
||||
if (node.object === 'text') {
|
||||
return {
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
};
|
||||
}
|
||||
const inline = inlinesCopy.shift();
|
||||
return {
|
||||
type: 'annotation',
|
||||
body: inline?.body ?? null,
|
||||
children: [
|
||||
{
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
highlighted: Boolean(lineBlock.data.highlighted),
|
||||
tokens,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -26,11 +26,8 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
href={file.downloadURL}
|
||||
download={file.name}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: block.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
className={tcls(
|
||||
'group/file',
|
||||
|
||||
@@ -27,11 +27,8 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
|
||||
href={resolved.href}
|
||||
className="underline underline-offset-2 text-primary hover:text-primary-700 transition-colors"
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
>
|
||||
<Inlines
|
||||
|
||||
@@ -19,11 +19,8 @@ export async function Mention(props: InlineProps<DocumentInlineMention>) {
|
||||
<StyledLink
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: inline.data.ref,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
>
|
||||
{resolved.text}
|
||||
|
||||
@@ -17,10 +17,12 @@ import './scalar.css';
|
||||
* Render an OpenAPI block.
|
||||
*/
|
||||
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
|
||||
const { style } = props;
|
||||
const { block, style } = props;
|
||||
return (
|
||||
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
|
||||
<OpenAPIBody {...props} />
|
||||
<React.Suspense fallback={<OpenAPIFallback />}>
|
||||
<OpenAPIBody {...props} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,11 +166,8 @@ export async function RecordCard(
|
||||
'dark:hover:before:ring-light/4',
|
||||
])}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: targetRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: targetRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -168,14 +168,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
insights={
|
||||
ref.file
|
||||
? {
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'file',
|
||||
file: ref.file.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
target: {
|
||||
kind: 'file',
|
||||
file: ref.file.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -228,11 +225,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
insights={
|
||||
contentRef
|
||||
? {
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -266,11 +260,8 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
key={index}
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: contentRef,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
>
|
||||
{resolved.text}
|
||||
|
||||
@@ -130,10 +130,10 @@ const textColorToStyle: { [color in DocumentMarkColor['data']['text']]: ClassVal
|
||||
|
||||
const backgroundColorToStyle: { [color in DocumentMarkColor['data']['background']]: ClassValue } = {
|
||||
default: [],
|
||||
blue: ['bg-mark-blue'],
|
||||
red: ['bg-mark-red'],
|
||||
green: ['bg-mark-green'],
|
||||
yellow: ['bg-mark-yellow'],
|
||||
purple: ['bg-mark-purple'],
|
||||
orange: ['bg-mark-orange'],
|
||||
blue: ['bg-blue-200', 'dark:bg-blue-900'],
|
||||
red: ['bg-red-200', 'dark:bg-red-900'],
|
||||
green: ['bg-green-200', 'dark:bg-green-900'],
|
||||
yellow: ['bg-yellow-100', 'dark:bg-yellow-900'],
|
||||
purple: ['bg-purple-200', 'dark:bg-purple-900'],
|
||||
orange: ['bg-orange-200', 'dark:bg-orange-900'],
|
||||
};
|
||||
|
||||
@@ -54,11 +54,8 @@ async function FooterLink(props: { link: CustomizationContentLink; context: Cont
|
||||
'underline-offset-2',
|
||||
)}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Footer,
|
||||
},
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Footer,
|
||||
}}
|
||||
>
|
||||
{link.title}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { SearchButton } from '../Search';
|
||||
import { SiteSectionTabs } from '../SiteSections';
|
||||
import { HeaderMobileMenu } from './HeaderMobileMenu';
|
||||
|
||||
const MAX_HEADER_LINKS_FOR_BIG_SEARCHBAR = 4;
|
||||
|
||||
/**
|
||||
* Render the header for the space.
|
||||
*/
|
||||
@@ -73,7 +75,7 @@ export function Header(props: {
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
'lg:gap-8',
|
||||
'lg:gap-6',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-between',
|
||||
@@ -90,6 +92,11 @@ export function Header(props: {
|
||||
'flex max-w-full',
|
||||
isMultiVariants && 'page-no-toc:max-[400px]:w-full',
|
||||
'shrink min-w-0 gap-2 lg:gap-4 justify-start items-center',
|
||||
customization.header.links.length <=
|
||||
MAX_HEADER_LINKS_FOR_BIG_SEARCHBAR
|
||||
? 'lg:basis-72'
|
||||
: '',
|
||||
isMultiVariants && 'page-no-toc:lg:basis-auto',
|
||||
)}
|
||||
>
|
||||
<HeaderMobileMenu
|
||||
@@ -130,33 +137,29 @@ export function Header(props: {
|
||||
</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',
|
||||
'gap-4',
|
||||
'md:min-w-56',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'justify-self-end',
|
||||
'items-center',
|
||||
'grow-0 shrink-0',
|
||||
customization.header.links.length <=
|
||||
MAX_HEADER_LINKS_FOR_BIG_SEARCHBAR
|
||||
? [
|
||||
'lg:grow',
|
||||
'lg:min-w-40',
|
||||
'max-w-lg',
|
||||
'lg:ml-[max(calc((100%-18rem-48rem-3rem)/2),1.5rem)]',
|
||||
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]',
|
||||
'lg:mr-auto',
|
||||
'page-no-toc:xl:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),1.5rem)]',
|
||||
isMultiVariants &&
|
||||
'page-no-toc:lg:ml-0 page-no-toc:xl:ml-0',
|
||||
'order-last',
|
||||
'lg:order-[unset]',
|
||||
]
|
||||
: ['order-last'],
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
@@ -172,7 +175,9 @@ export function Header(props: {
|
||||
'text-header-link/8',
|
||||
'dark:text-header-link/8',
|
||||
'hover:text-header-link',
|
||||
'focus:text-header-link',
|
||||
'dark:hover:text-header-link',
|
||||
'dark:focus:text-header-link',
|
||||
|
||||
'ring-header-link/4',
|
||||
'dark:ring-header-link/4',
|
||||
@@ -182,7 +187,9 @@ export function Header(props: {
|
||||
'[&_svg]:text-header-link/10',
|
||||
'dark:[&_svg]:text-header-link/10',
|
||||
'[&_.shortcut]:text-header-link/8',
|
||||
'[&_.shortcut_kbd]:border-header-link/2',
|
||||
'dark:[&_.shortcut]:text-header-link/8',
|
||||
'dark:[&_.shortcut_kbd]:border-header-link/2',
|
||||
|
||||
'contrast-more:bg-header-background',
|
||||
'contrast-more:text-header-link',
|
||||
@@ -218,6 +225,27 @@ export function Header(props: {
|
||||
</SearchButton>
|
||||
</Suspense>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function HeaderLink(props: {
|
||||
if (link.links && link.links.length > 0) {
|
||||
return (
|
||||
<Dropdown
|
||||
className="shrink"
|
||||
className="shrink left-auto right-0"
|
||||
button={(buttonProps) => {
|
||||
if (!target || !link.to) {
|
||||
return (
|
||||
@@ -145,11 +145,8 @@ function HeaderItemButton(
|
||||
}[linkStyle],
|
||||
)}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
@@ -162,7 +159,7 @@ function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset
|
||||
return tcls(
|
||||
'flex items-center shrink',
|
||||
'hover:text-header-link-400 dark:hover:text-light',
|
||||
'min-w-0',
|
||||
'min-w-0 hover:min-w-max',
|
||||
'contrast-more:underline',
|
||||
|
||||
props.headerPreset === CustomizationHeaderPreset.Default
|
||||
@@ -178,11 +175,8 @@ function HeaderItemLink(props: Omit<HeaderLinkNavItemProps, 'linkStyle'>) {
|
||||
href={href}
|
||||
className={getHeaderLinkClassName({ headerPreset })}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
target: linkTarget,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
@@ -225,13 +219,7 @@ async function SubHeaderLink(props: {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
href={target.href}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
}}
|
||||
insights={{ target: link.to, position: SiteInsightsLinkPosition.Header }}
|
||||
>
|
||||
{link.title}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function HeaderLinkMore(props: {
|
||||
|
||||
return (
|
||||
<div className={`${styles.linkEllipsis} items-center z-20`}>
|
||||
<Dropdown button={renderButton} className="-translate-x-48 md:translate-x-0">
|
||||
<Dropdown button={renderButton} className="left-auto right-0 md:right-auto md:left-0">
|
||||
<DropdownMenu>
|
||||
{links.map((link, index) => (
|
||||
<MoreMenuLink key={index} link={link} context={context} />
|
||||
@@ -82,11 +82,8 @@ async function MoreMenuLink(props: {
|
||||
insights={
|
||||
link.to
|
||||
? {
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
},
|
||||
target: link.to,
|
||||
position: SiteInsightsLinkPosition.Header,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function HeaderLinks({ children }: HeaderLinksProps) {
|
||||
<div
|
||||
className={tcls(
|
||||
styles.containerHeaderlinks,
|
||||
'grow shrink flex justify-end items-center gap-x-4 lg:gap-x-6 min-w-9 z-20 lg:[&>.button+.button]:-ml-2',
|
||||
'grow-[2.5] shrink flex justify-end items-center gap-x-4 lg:gap-x-6 min-w-9 z-20 lg:[&>.button+.button]:-ml-2',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -95,7 +95,7 @@ function LogoFallback(props: HeaderLogoProps) {
|
||||
<div
|
||||
className={tcls(
|
||||
'text-pretty',
|
||||
'line-clamp-3',
|
||||
'line-clamp-2',
|
||||
'tracking-tight',
|
||||
'max-w-[18ch]',
|
||||
'lg:max-w-[24ch]',
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SpacesDropdown(props: { space: Space; spaces: Space[]; className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={tcls('line-clamp-2', 'grow')}>{space.title}</span>
|
||||
<span className={tcls('line-clamp-1', 'grow')}>{space.title}</span>
|
||||
<DropdownChevron />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.containerHeaderlinks {
|
||||
container-type: inline-size;
|
||||
container-name: headerlinks;
|
||||
}
|
||||
|
||||
.linkEllipsis {
|
||||
display: none;
|
||||
& div > a {
|
||||
@@ -10,6 +5,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.containerHeaderlinks {
|
||||
container-type: inline-size;
|
||||
container-name: headerlinks;
|
||||
}
|
||||
|
||||
@container headerlinks ( width < 150px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 1) {
|
||||
display: none;
|
||||
@@ -43,7 +43,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 600px ) {
|
||||
@container headerlinks ( width < 550px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 750px ) {
|
||||
@container headerlinks ( width < 650px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
@container headerlinks ( width < 850px ) {
|
||||
@container headerlinks ( width < 750px ) {
|
||||
.containerHeaderlinks > :nth-child(n + 6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as cookies from '@/lib/cookies';
|
||||
import { getSession } from './sessions';
|
||||
import { getVisitorId } from './visitorId';
|
||||
|
||||
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
||||
type SiteEventName = api.SiteInsightsEvent['type'];
|
||||
|
||||
/**
|
||||
* Global context for all events in the session.
|
||||
@@ -46,14 +46,15 @@ interface InsightsEventOptions {
|
||||
/**
|
||||
* Input data for an event.
|
||||
*/
|
||||
export type TrackEventInput<EventName extends InsightsEventName = InsightsEventName> = {
|
||||
type: EventName;
|
||||
} & Omit<Extract<api.SiteInsightsEvent, { type: EventName }>, 'location' | 'session'>;
|
||||
type TrackEventInput<EventName extends SiteEventName> = { type: EventName } & Omit<
|
||||
Extract<api.SiteInsightsEvent, { type: EventName }>,
|
||||
'location' | 'session'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Callback to track an event.
|
||||
*/
|
||||
type TrackEventCallback = <EventName extends InsightsEventName>(
|
||||
type TrackEventCallback = <EventName extends SiteEventName>(
|
||||
event: TrackEventInput<EventName>,
|
||||
ctx?: InsightsEventPageContext,
|
||||
options?: InsightsEventOptions,
|
||||
@@ -79,7 +80,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
[pathname: string]:
|
||||
| {
|
||||
url: string;
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
events: TrackEventInput<SiteEventName>[];
|
||||
context: InsightsEventContext;
|
||||
pageContext?: InsightsEventPageContext;
|
||||
}
|
||||
@@ -151,7 +152,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
|
||||
const trackEvent: TrackEventCallback = useEventCallback(
|
||||
(
|
||||
event: TrackEventInput<InsightsEventName>,
|
||||
event: TrackEventInput<SiteEventName>,
|
||||
ctx?: InsightsEventPageContext,
|
||||
options?: InsightsEventOptions,
|
||||
) => {
|
||||
@@ -252,7 +253,7 @@ function sendEvents(args: {
|
||||
*/
|
||||
function transformEvents(input: {
|
||||
url: string;
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
events: TrackEventInput<SiteEventName>[];
|
||||
context: InsightsEventContext;
|
||||
pageContext: InsightsEventPageContext;
|
||||
visitorId: string;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Site,
|
||||
SiteAdsStatus,
|
||||
SiteCustomizationSettings,
|
||||
SiteInsightsAdPlacement,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
@@ -13,7 +12,7 @@ import React from 'react';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { getDocumentSections } from '@/lib/document-sections';
|
||||
import { getDocumentSections } from '@/lib/document';
|
||||
import { getAbsoluteHref } from '@/lib/links';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -138,7 +137,6 @@ export async function PageAside(props: {
|
||||
<div
|
||||
className={tcls(
|
||||
'overflow-auto',
|
||||
'overflow-x-visible',
|
||||
'flex-1',
|
||||
'flex',
|
||||
'flex-col',
|
||||
@@ -188,7 +186,7 @@ export async function PageAside(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-tint',
|
||||
'hover:text-primary',
|
||||
'py-2',
|
||||
)}
|
||||
>
|
||||
@@ -213,7 +211,7 @@ export async function PageAside(props: {
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'text-sm',
|
||||
'hover:text-tint',
|
||||
'hover:text-primary',
|
||||
'py-2',
|
||||
)}
|
||||
>
|
||||
@@ -228,7 +226,7 @@ export async function PageAside(props: {
|
||||
zoneId={
|
||||
site?.ads && site.ads.status === SiteAdsStatus.Live ? site.ads.zoneId : null
|
||||
}
|
||||
placement={SiteInsightsAdPlacement.Aside}
|
||||
placement="page.aside"
|
||||
spaceId={space.id}
|
||||
siteAdsStatus={site?.ads && site.ads.status ? site.ads.status : undefined}
|
||||
ignore={process.env.NODE_ENV !== 'production'}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
import { useScrollActiveId } from '@/components/hooks';
|
||||
import type { DocumentSection } from '@/lib/document-sections';
|
||||
import { DocumentSection } from '@/lib/document';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AsideSectionHighlight } from './AsideSectionHighlight';
|
||||
@@ -53,11 +53,11 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
<AsideSectionHighlight
|
||||
transition={springCurve}
|
||||
className={tcls(
|
||||
'sidebar-list-default:hidden',
|
||||
section?.depth > 1
|
||||
? [
|
||||
'sidebar-list-default:rounded-l-none',
|
||||
'sidebar-list-line:rounded-l-none',
|
||||
'sidebar-list-default:border-l',
|
||||
]
|
||||
: [
|
||||
'sidebar-list-default:ml-3',
|
||||
@@ -113,8 +113,6 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
|
||||
|
||||
'contrast-more:font-semibold',
|
||||
|
||||
'sidebar-list-default:border-tint',
|
||||
|
||||
'hover:bg-tint/3',
|
||||
'dark:hover:bg-tint-400/3',
|
||||
'sidebar-list-pill:hover:bg-transparent',
|
||||
|
||||
@@ -120,30 +120,27 @@ export function PageBody(props: {
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'flex-wrap',
|
||||
'gap-4',
|
||||
'items-center',
|
||||
'mt-6',
|
||||
'max-w-3xl',
|
||||
'mx-auto',
|
||||
'page-api-block:ml-0',
|
||||
'text-dark/7',
|
||||
'dark:text-light/7',
|
||||
'contrast-more:text-dark',
|
||||
'dark:contrast-more:text-light',
|
||||
)}
|
||||
>
|
||||
{updatedAt ? (
|
||||
<p className={tcls('text-sm mr-auto')}>
|
||||
<p
|
||||
className={tcls(
|
||||
'flex-1',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'dark:text-light/5',
|
||||
)}
|
||||
>
|
||||
{t(language, 'page_last_modified', <DateRelative value={updatedAt} />)}
|
||||
</p>
|
||||
) : null}
|
||||
{withPageFeedback ? (
|
||||
<PageFeedbackForm
|
||||
className={page.layout.outline ? 'xl:hidden' : ''}
|
||||
orientation="horizontal"
|
||||
pageId={page.id}
|
||||
/>
|
||||
<PageFeedbackForm orientation="horizontal" pageId={page.id} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -52,11 +52,8 @@ export async function PageBodyBlankslate(props: {
|
||||
title={child.title}
|
||||
href={resolved.href}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: child.target,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
},
|
||||
target: child.target,
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -51,14 +51,11 @@ export async function PageFooterNavigation(props: {
|
||||
title={previous.title}
|
||||
href={previousHref}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: previous.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: previous.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
reversed
|
||||
/>
|
||||
@@ -70,14 +67,11 @@ export async function PageFooterNavigation(props: {
|
||||
title={next.title}
|
||||
href={nextHref}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: next.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: next.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Content,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { PageFeedbackRating } from '@gitbook/api';
|
||||
import React, { ButtonHTMLAttributes } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
@@ -9,9 +9,6 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getVisitorId, useTrackEvent } from '../Insights';
|
||||
import { postPageFeedback } from './server-actions';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* Form to submit feedback on a page.
|
||||
@@ -24,13 +21,10 @@ export function PageFeedbackForm(props: {
|
||||
const { orientation = 'vertical', pageId, className } = props;
|
||||
const languages = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [rating, setRating] = React.useState<PageFeedbackRating>();
|
||||
const [comment, setComment] = React.useState('');
|
||||
const [submitted, setSubmitted] = React.useState(false);
|
||||
|
||||
const onSubmitRating = async (rating: PageFeedbackRating) => {
|
||||
setRating(rating);
|
||||
const onSubmit = async (rating: PageFeedbackRating) => {
|
||||
setSubmitted(true);
|
||||
const visitorId = await getVisitorId();
|
||||
await postPageFeedback({ pageId, visitorId, rating });
|
||||
|
||||
@@ -42,128 +36,92 @@ export function PageFeedbackForm(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitComment = (rating: PageFeedbackRating, comment: string) => {
|
||||
setSubmitted(true);
|
||||
|
||||
trackEvent({
|
||||
type: 'page_post_feedback_comment',
|
||||
feedback: {
|
||||
rating,
|
||||
comment,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Focus the comment input when the rating is submitted
|
||||
React.useEffect(() => {
|
||||
if (!!rating) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [rating]);
|
||||
|
||||
return (
|
||||
<div className={tcls('flex flex-col gap-3 text-sm', className)}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p>{t(languages, 'was_this_helpful')}</p>
|
||||
<div className="bg-light-1 dark:bg-dark-1 border border-dark/2 dark:border-light/2 contrast-more:border-dark dark:contrast-more:border-light rounded-full">
|
||||
<div className="flex">
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
orientation === 'vertical'
|
||||
? ['flex-col', 'items-start']
|
||||
: ['flex-row', 'items-center'],
|
||||
'gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className={tcls('text-sm')}>{t(languages, 'was_this_helpful')}</p>
|
||||
<div
|
||||
className={tcls(
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'flex-row',
|
||||
'rounded-full',
|
||||
'straight-corners:rounded-sm',
|
||||
'ring-1',
|
||||
'ring-inset',
|
||||
'ring-dark/2',
|
||||
'h-8',
|
||||
'dark:ring-light/1',
|
||||
)}
|
||||
>
|
||||
{submitted ? (
|
||||
<p className={tcls('text-sm', 'px-4')}>
|
||||
{t(languages, 'was_this_helpful_thank_you')}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={tcls(
|
||||
'inline-flex',
|
||||
'[&>*:last-child]:rounded-r-full',
|
||||
'[&>*:first-child]:rounded-l-full',
|
||||
'straight-corners:[&>*:last-child]:rounded-none',
|
||||
'straight-corners:[&>*:first-child]:rounded-none',
|
||||
)}
|
||||
>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Bad}
|
||||
rating={0}
|
||||
label={tString(languages, 'was_this_helpful_negative')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Bad)}
|
||||
active={rating == PageFeedbackRating.Bad}
|
||||
disabled={rating !== undefined}
|
||||
onClick={() => onSubmit(PageFeedbackRating.Bad)}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Ok}
|
||||
rating={1}
|
||||
label={tString(languages, 'was_this_helpful_neutral')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Ok)}
|
||||
active={rating == PageFeedbackRating.Ok}
|
||||
disabled={rating !== undefined}
|
||||
onClick={() => onSubmit(PageFeedbackRating.Ok)}
|
||||
/>
|
||||
<RatingButton
|
||||
rating={PageFeedbackRating.Good}
|
||||
rating={2}
|
||||
label={tString(languages, 'was_this_helpful_positive')}
|
||||
onClick={() => onSubmitRating(PageFeedbackRating.Good)}
|
||||
active={rating == PageFeedbackRating.Good}
|
||||
disabled={rating !== undefined}
|
||||
onClick={() => onSubmit(PageFeedbackRating.Good)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rating ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{!submitted ? (
|
||||
<>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
name="comment"
|
||||
className="grow ring-1 ring-inset bg-light-1 dark:bg-dark-1 ring-dark/2 dark:ring-light/2 contrast-more:ring-dark dark:contrast-more:ring-light min-h-16 max-h-40 rounded straight-corners:rounded-none p-2 placeholder:text-sm placeholder:text-dark/6 dark:placeholder:text-light/6 contrast-more:placeholder:text-dark dark:contrast-more:placeholder:text-light"
|
||||
placeholder={tString(languages, 'was_this_helpful_comment')}
|
||||
aria-label={tString(languages, 'was_this_helpful_comment')}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
value={comment}
|
||||
rows={3}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
/>
|
||||
<div className="flex gap-4 items-center justify-between">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onSubmitComment(rating, comment)}
|
||||
>
|
||||
{t(languages, 'submit')}
|
||||
</Button>
|
||||
{comment.length > MAX_COMMENT_LENGTH * 0.8 ? (
|
||||
<span
|
||||
className={
|
||||
comment.length == MAX_COMMENT_LENGTH
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{comment.length} / {MAX_COMMENT_LENGTH}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>{t(languages, 'was_this_helpful_thank_you')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RatingButton(
|
||||
props: {
|
||||
rating: PageFeedbackRating;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
active: boolean;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
) {
|
||||
const { rating, label, onClick, active, ...attr } = props;
|
||||
function RatingButton(props: { rating: number; label: string; onClick: () => void }) {
|
||||
const { rating, label, onClick } = props;
|
||||
|
||||
const ratingIcon =
|
||||
{
|
||||
bad: <IconBad />,
|
||||
ok: <IconOk />,
|
||||
good: <IconGood />,
|
||||
}[rating] ?? null;
|
||||
rating === 0 ? <Icon0 /> : rating === 1 ? <Icon1 /> : rating === 2 ? <Icon2 /> : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={tcls(
|
||||
'p-2 hover:text-tint dark:hover:text-tint-400 hover:bg-tint/2 first:pl-2.5 last:pr-2.5 first:rounded-l-full last:rounded-r-full',
|
||||
'disabled:cursor-not-allowed disabled:hover:bg-inherit disabled:hover:text-inherit disabled:dark:hover:text-inherit',
|
||||
'contrast-more:hover:ring-1 ring-tint',
|
||||
active
|
||||
? 'bg-tint/4 text-tint-700 dark:text-tint-300 disabled:hover:bg-tint/4 disabled:hover:text-tint-700 dark:disabled:hover:text-tint-300 contrast-more:ring-2 contrast-more:hover:ring-2'
|
||||
: 'disabled:opacity-7 disabled:contrast-more:ring-0',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'h-8',
|
||||
'w-8',
|
||||
'rounded-sm',
|
||||
'text-dark/7',
|
||||
'hover:bg-tint/4',
|
||||
'hover:text-tint-600',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:text-tint-300',
|
||||
'dark:hover:bg-tint-300/2',
|
||||
)}
|
||||
type="button"
|
||||
{...attr}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
@@ -173,9 +131,9 @@ function RatingButton(
|
||||
);
|
||||
}
|
||||
|
||||
const IconBad = () => (
|
||||
const Icon0 = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
@@ -184,9 +142,9 @@ const IconBad = () => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const IconOk = () => (
|
||||
const Icon1 = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
@@ -195,9 +153,9 @@ const IconOk = () => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const IconGood = () => (
|
||||
const Icon2 = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.2" />
|
||||
<circle cx="9" cy="9" r="9" fill="currentColor" fillOpacity="0.24" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
||||
@@ -72,7 +72,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
'transition-all',
|
||||
'hover:shadow-md',
|
||||
'hover:scale-102',
|
||||
'hover:ring-dark/2',
|
||||
'hover:ring-dark/4',
|
||||
'hover:text-dark/10',
|
||||
'focus:shadow-md',
|
||||
'focus:scale-102',
|
||||
@@ -93,7 +93,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
'dark:contrast-more:focus:ring-light',
|
||||
|
||||
'active:shadow-sm',
|
||||
'active:scale-98',
|
||||
'active:scale-100',
|
||||
|
||||
'md:justify-start',
|
||||
'md:w-full',
|
||||
@@ -131,18 +131,30 @@ const Shortcut = () => {
|
||||
className={tcls(
|
||||
'shortcut',
|
||||
'hidden',
|
||||
'md:inline',
|
||||
'md:flex',
|
||||
'justify-end',
|
||||
'text-xs',
|
||||
'font-mono',
|
||||
'text-dark/7',
|
||||
'leading-none',
|
||||
'contrast-more:text-dark',
|
||||
'dark:text-light-4/7',
|
||||
'contrast-more:dark:text-light',
|
||||
'whitespace-nowrap',
|
||||
`[font-feature-settings:"calt",_"case"]`,
|
||||
'gap-0.5',
|
||||
'shrink-0',
|
||||
'-mr-1',
|
||||
)}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl +'} K
|
||||
<kbd
|
||||
className={`rounded border border-dark/2 dark:border-light/2 px-1 min-w-5 h-5 flex justify-center items-center ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
|
||||
</kbd>
|
||||
<kbd className="rounded border border-dark/2 dark:border-light/2 size-5 flex justify-center items-center">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SearchState, UpdateSearchState, useSearch } from './useSearch';
|
||||
import { LoadingPane } from '../primitives/LoadingPane';
|
||||
|
||||
interface SearchModalProps {
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
spaceTitle: string;
|
||||
isMultiVariants: boolean;
|
||||
@@ -146,6 +147,7 @@ function SearchModalBody(
|
||||
) {
|
||||
const {
|
||||
pointer,
|
||||
spaceId,
|
||||
revisionId,
|
||||
spaceTitle,
|
||||
withAsk,
|
||||
@@ -307,6 +309,7 @@ function SearchModalBody(
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
pointer={pointer}
|
||||
spaceId={spaceId}
|
||||
revisionId={revisionId}
|
||||
global={isMultiVariants && state.global}
|
||||
query={state.query}
|
||||
|
||||
@@ -5,7 +5,6 @@ import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { iterateStreamResponse } from '@/lib/actions';
|
||||
import { SiteContentPointer } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -13,10 +12,10 @@ import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
import { SearchQuestionResultItem } from './SearchQuestionResultItem';
|
||||
import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||
import {
|
||||
getRecommendedQuestions,
|
||||
OrderedComputedResult,
|
||||
searchSiteSpaceContent,
|
||||
searchAllSiteContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Loading } from '../primitives';
|
||||
@@ -32,12 +31,6 @@ type ResultType =
|
||||
| { type: 'question'; id: string; query: string }
|
||||
| { type: 'recommended-question'; id: string; question: string };
|
||||
|
||||
/**
|
||||
* We cache the recommended questions globally to avoid calling the API multiple times
|
||||
* when re-opening the search modal.
|
||||
*/
|
||||
let cachedRecommendedQuestions: null | ResultType[] = null;
|
||||
|
||||
/**
|
||||
* Fetch the results of the keyboard navigable elements to display for a query:
|
||||
* - Recommended questions if no query is provided.
|
||||
@@ -48,6 +41,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
global: boolean;
|
||||
withAsk: boolean;
|
||||
@@ -56,7 +50,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>,
|
||||
) {
|
||||
const { children, query, pointer, revisionId, withAsk, global, onSwitchToAsk } = props;
|
||||
const { children, query, pointer, spaceId, revisionId, withAsk, global, onSwitchToAsk } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -66,6 +60,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}>({ results: [], fetching: true });
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
const suggestedQuestionsRef = React.useRef<null | ResultType[]>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -74,50 +69,42 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedRecommendedQuestions) {
|
||||
setResultsState({ results: cachedRecommendedQuestions, fetching: false });
|
||||
if (suggestedQuestionsRef.current) {
|
||||
setResultsState({ results: suggestedQuestionsRef.current, fetching: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
setResultsState({ results: [], fetching: true });
|
||||
getRecommendedQuestions(spaceId).then((questions) => {
|
||||
if (!questions) {
|
||||
if (!cancelled) {
|
||||
setResultsState({ results: [], fetching: false });
|
||||
}
|
||||
captureException(
|
||||
new Error(`corrupt-cache: getRecommendedQuestions is ${questions}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We currently have a bug where the same question can be returned multiple times.
|
||||
// This is a workaround to avoid that.
|
||||
const questions = new Set<string>();
|
||||
const recommendedQuestions: ResultType[] = [];
|
||||
const results = questions.map((question) => ({
|
||||
type: 'recommended-question',
|
||||
id: question,
|
||||
question: question,
|
||||
})) satisfies ResultType[];
|
||||
|
||||
suggestedQuestionsRef.current = results;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = streamRecommendedQuestions(pointer.organizationId, pointer.siteId);
|
||||
const stream = iterateStreamResponse(response);
|
||||
|
||||
for await (const { question } of stream) {
|
||||
if (questions.has(question)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
questions.add(question);
|
||||
recommendedQuestions.push({
|
||||
type: 'recommended-question',
|
||||
id: question,
|
||||
question,
|
||||
});
|
||||
cachedRecommendedQuestions = recommendedQuestions;
|
||||
|
||||
if (!cancelled) {
|
||||
setResultsState({ results: [...recommendedQuestions], fetching: false });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
setResultsState({ results, fetching: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
} else {
|
||||
setResultsState((prev) => ({ results: prev.results, fetching: true }));
|
||||
@@ -155,7 +142,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [query, global, pointer, revisionId, withAsk, trackEvent]);
|
||||
}, [query, global, pointer, spaceId, revisionId, withAsk, trackEvent]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAsk) {
|
||||
|
||||
@@ -227,20 +227,12 @@ export const streamAskQuestion = streamResponse(async function* (
|
||||
});
|
||||
|
||||
/**
|
||||
* Stream a list of suggested questions for the site.
|
||||
* List suggested questions for a space.
|
||||
*/
|
||||
export const streamRecommendedQuestions = streamResponse(async function* (
|
||||
organizationId: string,
|
||||
siteId: string,
|
||||
) {
|
||||
const apiCtx = await api.api();
|
||||
const stream = apiCtx.client.orgs.streamRecommendedQuestionsInSite(organizationId, siteId);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
console.log('got question', chunk);
|
||||
yield chunk;
|
||||
}
|
||||
});
|
||||
export async function getRecommendedQuestions(spaceId: string): Promise<string[]> {
|
||||
const data = await api.getRecommendedQuestionsInSpace(spaceId);
|
||||
return data.questions;
|
||||
}
|
||||
|
||||
async function transformAnswer(
|
||||
answer: SearchAIAnswer,
|
||||
|
||||
@@ -183,6 +183,7 @@ export async function SpaceLayout(props: {
|
||||
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchModal
|
||||
spaceId={contentTarget.spaceId}
|
||||
revisionId={contentTarget.revisionId}
|
||||
spaceTitle={customization.title ?? space.title}
|
||||
withAsk={customization.aiSearch.enabled}
|
||||
|
||||
@@ -29,14 +29,11 @@ export async function PageDocumentItem(props: {
|
||||
href={href}
|
||||
pathname={getPagePath(rootPages, page)}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: page.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
target: {
|
||||
kind: 'page',
|
||||
page: page.id,
|
||||
},
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
}}
|
||||
descendants={
|
||||
page.pages && page.pages.length ? (
|
||||
|
||||
@@ -36,11 +36,8 @@ export async function PageLinkItem(props: { page: RevisionPageLink; context: Con
|
||||
'dark:hover:bg-light/2',
|
||||
)}
|
||||
insights={{
|
||||
type: 'link_click',
|
||||
link: {
|
||||
target: page.target,
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
},
|
||||
target: page.target,
|
||||
position: SiteInsightsLinkPosition.Sidebar,
|
||||
}}
|
||||
>
|
||||
<TOCPageIcon page={page} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
SiteCustomizationSettings,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
@@ -136,11 +135,7 @@ export function TableOfContents(props: {
|
||||
)}
|
||||
/>
|
||||
{customization.trademark.enabled ? (
|
||||
<Trademark
|
||||
space={space}
|
||||
customization={customization}
|
||||
placement={SiteInsightsTrademarkPlacement.Sidebar}
|
||||
/>
|
||||
<Trademark space={space} customization={customization} />
|
||||
) : null}
|
||||
</TOCScrollContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import {
|
||||
CustomizationSettings,
|
||||
SiteCustomizationSettings,
|
||||
Space,
|
||||
SiteInsightsTrademarkPlacement,
|
||||
} from '@gitbook/api';
|
||||
import { CustomizationSettings, SiteCustomizationSettings, Space } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link } from '../primitives';
|
||||
|
||||
/**
|
||||
* Trademark link to the GitBook.
|
||||
*/
|
||||
export function Trademark(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -71,9 +63,8 @@ export function Trademark(props: {
|
||||
export function TrademarkLink(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings | SiteCustomizationSettings;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
}) {
|
||||
const { space, customization, placement } = props;
|
||||
const { space, customization } = props;
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
const url = new URL('https://www.gitbook.com');
|
||||
@@ -82,7 +73,7 @@ export function TrademarkLink(props: {
|
||||
url.searchParams.set('utm_campaign', space.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
<a
|
||||
target="_blank"
|
||||
href={url.toString()}
|
||||
className={tcls(
|
||||
@@ -113,13 +104,9 @@ export function TrademarkLink(props: {
|
||||
'transition-colors',
|
||||
'pointer-events-auto',
|
||||
)}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement,
|
||||
}}
|
||||
>
|
||||
<Icon icon="gitbook" className={tcls('size-5', 'mr-3')} />
|
||||
{t(language, 'powered_by_gitbook')}
|
||||
</Link>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const HAS_INTERSECTION_OBSERVER = typeof IntersectionObserver !== 'undefined';
|
||||
|
||||
/**
|
||||
* Return true if the element has been in the viewport once.
|
||||
*/
|
||||
export function useHasBeenInViewport(
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
options?: Pick<IntersectionObserverInit, 'root' | 'rootMargin' | 'threshold'>,
|
||||
) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
// We set the element as visible if the IntersectionObserver API is not available.
|
||||
// we have to do it in the `useEffect` to be SSR compatible.
|
||||
if (!HAS_INTERSECTION_OBSERVER) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: options?.root,
|
||||
rootMargin: options?.rootMargin,
|
||||
threshold: options?.threshold,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [containerRef, options?.root, options?.rootMargin, options?.threshold]);
|
||||
return visible;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import * as api from '@gitbook/api';
|
||||
import NextLink, { LinkProps as NextLinkProps } from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
import { useTrackEvent, TrackEventInput } from '../Insights';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
|
||||
// Props from Next, which includes NextLinkProps and all the things anchor elements support.
|
||||
type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps> &
|
||||
@@ -12,11 +13,8 @@ type BaseLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof N
|
||||
} & React.RefAttributes<HTMLAnchorElement>;
|
||||
|
||||
export type LinkInsightsProps = {
|
||||
/** Event to track when the link is clicked. */
|
||||
insights?:
|
||||
| TrackEventInput<'ad_click'>
|
||||
| TrackEventInput<'link_click'>
|
||||
| TrackEventInput<'trademark_click'>;
|
||||
/** Target of the link, for insights. */
|
||||
insights?: api.SiteInsightsEventLinkClick['link'];
|
||||
};
|
||||
|
||||
export type LinkProps = Omit<BaseLinkProps, 'href'> &
|
||||
@@ -42,7 +40,7 @@ export const Link = React.forwardRef(function Link(
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (insights) {
|
||||
trackEvent(insights, undefined, {
|
||||
trackEvent({ type: 'link_click', link: insights }, undefined, {
|
||||
immediate: isExternal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { checkIsHttpURL, getImageSize, getResizedImageURLFactory } from '@/lib/i
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PolymorphicComponentProp } from './types';
|
||||
import { ZoomImage } from '@v2/components/images/ZoomImage';
|
||||
import { ZoomImage } from './ZoomImage';
|
||||
|
||||
export type ImageSize = { width: number; height: number };
|
||||
|
||||
|
||||
+47
-8
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import styles from './ZoomImage.module.css';
|
||||
|
||||
/**
|
||||
@@ -165,7 +166,7 @@ export function ZoomImage(
|
||||
startViewTransition(change);
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
className={classNames(
|
||||
props.className,
|
||||
zoomable ? styles.zoomImg : null,
|
||||
active ? styles.zoomImageActive : null,
|
||||
@@ -207,9 +208,19 @@ function ZoomImageModal(props: {
|
||||
return (
|
||||
<div
|
||||
data-testid="zoom-image-modal"
|
||||
className={clsx(
|
||||
className={classNames(
|
||||
styles.zoomModal,
|
||||
'fixed inset-0 z-50 flex items-center justify-center bg-light dark:bg-dark p-8',
|
||||
tcls(
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'z-50',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
'p-8',
|
||||
),
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -217,15 +228,43 @@ function ZoomImageModal(props: {
|
||||
src={src}
|
||||
alt={alt}
|
||||
crossOrigin={crossOrigin}
|
||||
className="max-w-full max-h-full object-contain bg-light dark:bg-dark"
|
||||
className={tcls(
|
||||
'max-w-full',
|
||||
'max-h-full',
|
||||
'object-contain',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="absolute top-5 right-5 flex flex-row items-center justify-center text-sm text-dark/6 dark:text-light/5 hover:text-primary p-4 dark:text-light/5 rounded-full bg-white dark:bg-dark/3 shadow-sm hover:shadow-md border-slate-300 dark:border-dark/2 border dark:text-light/5 hover:text-primary p-4 dark:text-light/5 rounded-full bg-white dark:bg-dark/3 shadow-sm hover:shadow-md border-slate-300 dark:border-dark/2 border"
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'top-5',
|
||||
'right-5',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'dark:text-light/5',
|
||||
'hover:text-primary',
|
||||
'p-4',
|
||||
'dark:text-light/5',
|
||||
'rounded-full',
|
||||
'bg-white',
|
||||
'dark:bg-dark/3',
|
||||
'shadow-sm',
|
||||
'hover:shadow-md',
|
||||
'border-slate-300',
|
||||
'dark:border-dark/2',
|
||||
'border',
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon icon="compress-wide" className="size-5" />
|
||||
<Icon icon="compress-wide" className={tcls('size-5')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -28,8 +28,6 @@ export const de = {
|
||||
was_this_helpful_neutral: 'Nicht sicher',
|
||||
was_this_helpful_negative: 'Nein',
|
||||
was_this_helpful_thank_you: 'Danke!',
|
||||
was_this_helpful_comment: 'Möchten Sie etwas hinzufügen?',
|
||||
submit: 'Absenden',
|
||||
annotation_button_label: 'Kommentar öffnen',
|
||||
code_copied: 'Kopiert!',
|
||||
code_copy: 'Kopieren',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user