mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-03 12:10:34 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc1fbe884b | |||
| cfccc44406 | |||
| 45b30c9c27 | |||
| 67f78368fb | |||
| 875b81b6b6 | |||
| d3c7f4ae01 | |||
| 020269d562 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook-v2': minor
|
||||
---
|
||||
|
||||
Setup structure and deployment for new version
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve performances by highlighting code client-side if the code block is offscreen
|
||||
+149
-11
@@ -18,7 +18,8 @@ jobs:
|
||||
checks: write
|
||||
statuses: write
|
||||
outputs:
|
||||
deployment_url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
deployment-url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
deployment-alias-url: ${{ steps.deploy.outputs.deployment-alias-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -38,7 +39,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 build:cloudflare
|
||||
run: bun run turbo build:cloudflare
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
|
||||
@@ -74,26 +75,163 @@ 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: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: ./.github/composite/setup-bun
|
||||
- name: Find GitHub Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: GitBook Preview
|
||||
body-includes: 'Summary of the deployments'
|
||||
|
||||
- name: Create or update GitHub comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
if: 1 && !startsWith(github.ref, 'refs/heads/main')
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
**GitBook Preview**
|
||||
Latest commit: [${{ steps.deploy.outputs.deployment-url }}](${{ steps.deploy.outputs.deployment-url }})
|
||||
PR: [${{ steps.deploy.outputs.deployment-alias-url }}](${{ steps.deploy.outputs.deployment-alias-url }})
|
||||
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) |
|
||||
edit-mode: replace
|
||||
visual-testing:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -111,7 +249,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() }}
|
||||
@@ -135,7 +273,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,5 +1,6 @@
|
||||
.next
|
||||
.vercel
|
||||
.open-next
|
||||
|
||||
# Generated
|
||||
packages/emoji-codepoints/index.ts
|
||||
|
||||
+5
-3
@@ -2,9 +2,10 @@
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"prettier": "^3.0.3",
|
||||
"turbo": "^2.3.4"
|
||||
"turbo": "^2.3.4",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"packageManager": "bun@1.1.18",
|
||||
"overrides": {
|
||||
@@ -15,8 +16,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev --ui=stream",
|
||||
"dev:v2": "turbo run dev:v2 --ui=stream",
|
||||
"build": "turbo run build",
|
||||
"build:cloudflare": "turbo run build:cloudflare",
|
||||
"build:v2": "turbo run build:v2",
|
||||
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint -- --fix",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# cloudflare
|
||||
.open-next
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,15 @@
|
||||
// @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;
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "gitbook-v2",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"next": "canary",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@gitbook/api": "^0.89.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
|
||||
import { createDynamicSiteContext } from '@/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>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SiteContentPage } from '@/components/routes/SiteContentPage';
|
||||
import { createDynamicSiteContext } from '@/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} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { SiteContentLayout } from '@/components/routes/SiteContentLayout';
|
||||
import { getSiteCacheTag } from '@/lib/cache';
|
||||
import { createStaticSiteContext } from '@/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>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { createStaticSiteContext } from '@/lib/context';
|
||||
import { getSiteCacheTag } from '@/lib/cache';
|
||||
import { SiteContentPage } from '@/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} />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
/**
|
||||
* Layout component to render the site content.
|
||||
*/
|
||||
export async function SiteContentLayout({
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
context: GitBookSiteContext;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { api } = context;
|
||||
const { data: publishedSite } = await api.orgs.getPublishedContentSite(
|
||||
context.organizationId,
|
||||
context.siteId,
|
||||
);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<h1>{publishedSite.site.title}</h1>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { GitBookSiteContext } from '@/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getSiteCacheTag(siteId: string) {
|
||||
return `sites/${siteId}`;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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 '@/lib/env';
|
||||
|
||||
/**
|
||||
* Generic context about rendering content.
|
||||
*/
|
||||
export interface GitBookContext {
|
||||
/**
|
||||
* API client to fetch data from GitBook.
|
||||
*/
|
||||
api: GitBookAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context when rendering a site.
|
||||
*/
|
||||
export interface GitBookSiteContext extends GitBookContext {
|
||||
/**
|
||||
* ID of the organization.
|
||||
*/
|
||||
organizationId: string;
|
||||
|
||||
/**
|
||||
* Site ID.
|
||||
*/
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site context, when rendering a static page.
|
||||
*/
|
||||
export async function createStaticSiteContext(url: string[]): Promise<GitBookSiteContext> {
|
||||
const context = createStaticContext();
|
||||
return fetchSiteContext(url, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site context, when rendering a dynamic page.
|
||||
*/
|
||||
export async function createDynamicSiteContext(url: string[]): Promise<GitBookSiteContext> {
|
||||
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<GitBookSiteContext> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 {
|
||||
api,
|
||||
};
|
||||
}
|
||||
|
||||
function getURLFromParams(input: string[]) {
|
||||
const url = new URL('https://' + input.join('/'));
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -0,0 +1,11 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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": {
|
||||
"@/*": ["./src/*"],
|
||||
"@v1/*": ["../gitbook/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
main = ".open-next/worker.js"
|
||||
name = "gitbook-v2"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
assets = { directory = ".open-next/assets", binding = "ASSETS" }
|
||||
@@ -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');
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { HighlightLine, RenderedInline } from './highlight';
|
||||
import type { BlockProps } from '../Block';
|
||||
import './theme.css';
|
||||
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
|
||||
import { highlightAction } from './highlight-action';
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a code-block client-side by calling a server actions to highlight the code.
|
||||
* 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 [lines, setLines] = useState<HighlightLine[]>(() => plainHighlight(block));
|
||||
useEffect(() => {
|
||||
highlightAction(block, inlines).then(setLines);
|
||||
}, [block, inlines]);
|
||||
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
}
|
||||
@@ -1,43 +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 { ServerCodeBlock } from './ServerCodeBlock';
|
||||
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>) {
|
||||
const { block, document, style, context, isEstimatedOffscreen } = 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
|
||||
export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
const { block, document, style, context } = props;
|
||||
const lines = await highlight(block);
|
||||
|
||||
const id = block.key!;
|
||||
|
||||
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}
|
||||
ancestorBlocks={[]}
|
||||
context={context}
|
||||
nodes={fragment.nodes}
|
||||
style={['space-y-4']}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return { inline, body };
|
||||
});
|
||||
function CodeHighlightToken(props: {
|
||||
token: HighlightToken;
|
||||
document: JSONDocument;
|
||||
context: DocumentContext;
|
||||
}) {
|
||||
const { token, document, context } = props;
|
||||
|
||||
if (isEstimatedOffscreen) {
|
||||
return <ClientCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
if (token.type === 'inline') {
|
||||
return (
|
||||
<Inline
|
||||
inline={token.inline}
|
||||
document={document}
|
||||
context={context}
|
||||
ancestorInlines={[]}
|
||||
>
|
||||
<CodeHighlightTokens
|
||||
tokens={token.children}
|
||||
document={document}
|
||||
context={context}
|
||||
/>
|
||||
</Inline>
|
||||
);
|
||||
}
|
||||
|
||||
return <ServerCodeBlock block={block} style={style} inlines={richInlines} />;
|
||||
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,36 +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;
|
||||
|
||||
&.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)] [counter-increment:line] dark:before:text-light/4;
|
||||
|
||||
&.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,190 +0,0 @@
|
||||
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { CopyCodeButton } from './CopyCodeButton';
|
||||
import type { HighlightLine, HighlightToken } from './highlight';
|
||||
import { AnnotationPopover } from '../Annotation/AnnotationPopover';
|
||||
import { 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 function ClientCodeBlockRenderer(props: CodeBlockRendererProps) {
|
||||
const { block, style, lines } = props;
|
||||
|
||||
const id = block.key!;
|
||||
|
||||
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="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}
|
||||
key={index}
|
||||
line={line}
|
||||
lineIndex={index + 1}
|
||||
isLast={index === lines.length - 1}
|
||||
withLineNumbers={withLineNumbers}
|
||||
withWrap={withWrap}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeHighlightLine(props: {
|
||||
block: DocumentBlockCode;
|
||||
line: HighlightLine;
|
||||
lineIndex: number;
|
||||
isLast: boolean;
|
||||
withLineNumbers: boolean;
|
||||
withWrap: boolean;
|
||||
}) {
|
||||
const { line, isLast, withLineNumbers } = props;
|
||||
return (
|
||||
<span className={tcls('highlight-line', line.highlighted && 'highlighted')}>
|
||||
{withLineNumbers ? (
|
||||
<span
|
||||
className={tcls('highlight-line-number', line.highlighted && 'highlighted')}
|
||||
></span>
|
||||
) : null}
|
||||
|
||||
<span className="highlight-line-content">
|
||||
<CodeHighlightTokens tokens={line.tokens} />
|
||||
{isLast ? null : !withLineNumbers && line.tokens.length === 0 && 0 ? (
|
||||
<span className="ew">{'\u200B'}</span>
|
||||
) : (
|
||||
'\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;
|
||||
|
||||
if (token.type === 'annotation') {
|
||||
return (
|
||||
<AnnotationPopover body={token.body}>
|
||||
<CodeHighlightTokens tokens={token.children} />
|
||||
</AnnotationPopover>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -53,8 +53,7 @@ export function PlainCodeBlock(props: { code: string; syntax: string }) {
|
||||
}}
|
||||
block={block}
|
||||
ancestorBlocks={[]}
|
||||
// We optimize perf by default
|
||||
isEstimatedOffscreen
|
||||
isEstimatedOffscreen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { highlight, RenderedInline } from './highlight';
|
||||
import type { BlockProps } from '../Block';
|
||||
import './theme.css';
|
||||
import { ClientCodeBlockRenderer } from './CodeBlockRenderer';
|
||||
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a code-block server-side.
|
||||
*/
|
||||
export async function ServerCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines } = props;
|
||||
const lines = await highlight(block, inlines);
|
||||
return <ClientCodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { DocumentBlockCode } from '@gitbook/api';
|
||||
|
||||
import { highlight, RenderedInline } from './highlight';
|
||||
|
||||
/**
|
||||
* Server action to highlight a code block.
|
||||
* By using a server action, we can avoid loading the highlighter on the client-side
|
||||
* and increasing the bundle size.
|
||||
*/
|
||||
export async function highlightAction(block: DocumentBlockCode, inlines: RenderedInline[]) {
|
||||
return highlight(block, inlines);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -12,8 +12,6 @@ import { asyncMutexFunction, singleton } from '@/lib/async';
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { trace } from '@/lib/tracing';
|
||||
|
||||
import { plainHighlight } from './plain-highlight';
|
||||
|
||||
export type HighlightLine = {
|
||||
highlighted: boolean;
|
||||
tokens: HighlightToken[];
|
||||
@@ -22,31 +20,28 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a code block while preserving inline elements.
|
||||
*/
|
||||
export async function highlight(
|
||||
block: DocumentBlockCode,
|
||||
inlines: RenderedInline[],
|
||||
): Promise<HighlightLine[]> {
|
||||
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);
|
||||
|
||||
inlines.sort((a, b) => {
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
||||
const highlighter = await loadHighlighter();
|
||||
await loadHighlighterLanguage(highlighter, langName);
|
||||
@@ -121,25 +116,10 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getInlines(block: DocumentBlockCode) {
|
||||
const inlines: InlineIndexed[] = [];
|
||||
getPlainCodeBlock(block, inlines);
|
||||
|
||||
inlines.sort((a, b) => {
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
||||
return inlines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a code block without highlighting it.
|
||||
*/
|
||||
export function plainHighlighting(
|
||||
block: DocumentBlockCode,
|
||||
inlines?: RenderedInline[],
|
||||
): HighlightLine[] {
|
||||
const inlinesCopy = Array.from(inlines ?? []);
|
||||
export function plainHighlighting(block: DocumentBlockCode): HighlightLine[] {
|
||||
return block.nodes.map((lineBlock) => {
|
||||
const tokens: HighlightToken[] = [];
|
||||
|
||||
@@ -150,10 +130,9 @@ export function plainHighlighting(
|
||||
content: getNodeText(node),
|
||||
});
|
||||
} else {
|
||||
const inline = inlinesCopy.shift();
|
||||
tokens.push({
|
||||
type: 'annotation',
|
||||
body: inline?.body ?? null,
|
||||
type: 'inline',
|
||||
inline: node,
|
||||
children: [
|
||||
{
|
||||
type: 'plain',
|
||||
@@ -173,7 +152,7 @@ export function plainHighlighting(
|
||||
|
||||
function matchTokenAndInlines(
|
||||
eat: () => PositionedToken | null,
|
||||
allInlines: RenderedInline[],
|
||||
allInlines: InlineIndexed[],
|
||||
): HighlightToken[] {
|
||||
const initialToken = eat();
|
||||
if (!initialToken) {
|
||||
@@ -181,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[] = [];
|
||||
@@ -197,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',
|
||||
@@ -212,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,
|
||||
@@ -225,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`);
|
||||
}
|
||||
@@ -236,8 +215,8 @@ function matchTokenAndInlines(
|
||||
});
|
||||
|
||||
result.push({
|
||||
type: 'annotation',
|
||||
body: inline.body,
|
||||
type: 'inline',
|
||||
inline: inline.inline,
|
||||
children,
|
||||
});
|
||||
|
||||
@@ -251,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) {
|
||||
@@ -268,8 +247,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
|
||||
|
||||
function getPlainCodeBlockLine(
|
||||
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
|
||||
inlines: InlineIndexed[],
|
||||
index: number,
|
||||
inlines?: InlineIndexed[],
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
@@ -278,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +341,7 @@ const loadHighlighter = singleton(async () => {
|
||||
});
|
||||
|
||||
const loadLanguagesMutex = asyncMutexFunction();
|
||||
|
||||
const loadHighlighterLanguage = async function loadHighlighterLanguage(
|
||||
async function loadHighlighterLanguage(
|
||||
highlighter: HighlighterGeneric<any, any>,
|
||||
lang: keyof typeof bundledLanguages,
|
||||
) {
|
||||
@@ -379,4 +355,4 @@ const loadHighlighterLanguage = async function loadHighlighterLanguage(
|
||||
async () => await highlighter.loadLanguage(lang),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,44 +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[] = [];
|
||||
|
||||
for (const node of lineBlock.nodes) {
|
||||
if (node.object === 'text') {
|
||||
tokens.push({
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
});
|
||||
} else {
|
||||
const inline = inlinesCopy.shift();
|
||||
tokens.push({
|
||||
type: 'annotation',
|
||||
body: inline?.body ?? null,
|
||||
children: [
|
||||
{
|
||||
type: 'plain',
|
||||
content: getNodeText(node),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
highlighted: !!lineBlock.data.highlighted,
|
||||
tokens,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,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';
|
||||
@@ -25,11 +25,11 @@ import { PageFeedbackForm } from '../PageFeedback';
|
||||
|
||||
function getTopOffset(props: { sectionsHeader: boolean; topHeader: boolean }) {
|
||||
if (props.topHeader && props.sectionsHeader) {
|
||||
return 'lg:top-[6.75rem] lg:max-h-[calc(100vh_-_6.75rem)]';
|
||||
return 'lg:top-[calc(100vh_-_6.75rem)] lg:max-h-[calc(100vh_-_6.75rem)]';
|
||||
}
|
||||
|
||||
if (props.topHeader) {
|
||||
return 'lg:top-16 lg:max-h-[calc(100vh_-_4rem)]';
|
||||
return 'lg:top-[calc(100vh_-_4rem)] lg:max-h-[calc(100vh_-_4rem)]';
|
||||
}
|
||||
|
||||
return 'lg:top-0 lg:max-h-screen';
|
||||
@@ -137,7 +137,7 @@ export async function PageAside(props: {
|
||||
</div>
|
||||
<div
|
||||
className={tcls(
|
||||
'overflow-auto',
|
||||
'overflow-y-auto',
|
||||
'overflow-x-visible',
|
||||
'flex-1',
|
||||
'flex',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { JSONDocument, ContentRef } from '@gitbook/api';
|
||||
|
||||
import { getNodeText } from './document';
|
||||
import { fetchOpenAPIBlock } from './openapi';
|
||||
import { ResolvedContentRef } from './references';
|
||||
|
||||
export interface DocumentSection {
|
||||
id: string;
|
||||
tag?: string;
|
||||
title: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a list of sections from a document.
|
||||
*/
|
||||
export async function getDocumentSections(
|
||||
document: JSONDocument,
|
||||
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
|
||||
): Promise<DocumentSection[]> {
|
||||
const sections: DocumentSection[] = [];
|
||||
let depth = 0;
|
||||
|
||||
for (const block of document.nodes) {
|
||||
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
|
||||
if (block.type === 'heading-1') {
|
||||
depth = 1;
|
||||
}
|
||||
const title = getNodeText(block);
|
||||
const id = block.meta.id;
|
||||
|
||||
sections.push({
|
||||
id,
|
||||
title,
|
||||
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (block.type === 'swagger' && block.meta?.id) {
|
||||
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
|
||||
if (operation) {
|
||||
sections.push({
|
||||
id: block.meta.id,
|
||||
tag: operation.method.toUpperCase(),
|
||||
title: operation.operation.summary ?? operation.path,
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import type {
|
||||
import {
|
||||
DocumentText,
|
||||
DocumentInline,
|
||||
DocumentFragment,
|
||||
JSONDocument,
|
||||
DocumentBlock,
|
||||
ContentRef,
|
||||
} from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import { fetchOpenAPIBlock } from './openapi';
|
||||
import { ResolvedContentRef } from './references';
|
||||
|
||||
export interface DocumentSection {
|
||||
id: string;
|
||||
tag?: string;
|
||||
@@ -30,6 +34,47 @@ export function hasFullWidthBlock(document: JSONDocument): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a list of sections from a document.
|
||||
*/
|
||||
export async function getDocumentSections(
|
||||
document: JSONDocument,
|
||||
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
|
||||
): Promise<DocumentSection[]> {
|
||||
const sections: DocumentSection[] = [];
|
||||
let depth = 0;
|
||||
|
||||
for (const block of document.nodes) {
|
||||
if ((block.type === 'heading-1' || block.type === 'heading-2') && block.meta?.id) {
|
||||
if (block.type === 'heading-1') {
|
||||
depth = 1;
|
||||
}
|
||||
const title = getNodeText(block);
|
||||
const id = block.meta.id;
|
||||
|
||||
sections.push({
|
||||
id,
|
||||
title,
|
||||
depth: block.type === 'heading-1' ? 1 : depth > 0 ? 2 : 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (block.type === 'swagger' && block.meta?.id) {
|
||||
const { data: operation } = await fetchOpenAPIBlock(block, resolveContentRef);
|
||||
if (operation) {
|
||||
sections.push({
|
||||
id: block.meta.id,
|
||||
tag: operation.method.toUpperCase(),
|
||||
title: operation.operation.summary ?? operation.path,
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text of a block/inline.
|
||||
*/
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"react": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "node ./bin/gen-list.js",
|
||||
"generate": "bun ./bin/gen-list.js",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsc -w",
|
||||
|
||||
+16
@@ -17,6 +17,16 @@
|
||||
"dependsOn": ["^build", "generate"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist"]
|
||||
},
|
||||
// Build the package for v2
|
||||
"build:v2": {
|
||||
"dependsOn": ["^build:v2", "^build", "generate"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist"]
|
||||
},
|
||||
// Build the package for v2 (cloudflare)
|
||||
"build:v2:cloudflare": {
|
||||
"dependsOn": ["^build:v2:cloudflare", "^build:v2", "generate"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist", ".open-next/**"]
|
||||
},
|
||||
// Build the package for Cloudflare Pages
|
||||
"build:cloudflare": {
|
||||
"dependsOn": ["^build", "generate"],
|
||||
@@ -45,6 +55,12 @@
|
||||
"cache": false,
|
||||
"dependsOn": ["generate"]
|
||||
},
|
||||
// Start the package in development mode for v2
|
||||
"dev:v2": {
|
||||
"persistent": true,
|
||||
"cache": false,
|
||||
"dependsOn": ["generate"]
|
||||
},
|
||||
// Script run when the package is deployed
|
||||
"release": {
|
||||
"dependsOn": ["^release", "build"],
|
||||
|
||||
Reference in New Issue
Block a user