Compare commits

...

7 Commits

Author SHA1 Message Date
Claire Chabas bc1fbe884b Merge branch 'main' into claire/RND-6068-fix-outline-position
* main:
  Setup package and deployment for v2 (#2791)
  Fix icons generate script (#2798)
  Revert "Highlight code client-side" (#2796)
2025-01-31 13:27:55 +01:00
Samy Pessé cfccc44406 Setup package and deployment for v2 (#2791) 2025-01-31 12:28:41 +01:00
Claire Chabas 45b30c9c27 Fix icons generate script (#2798) 2025-01-31 11:05:38 +01:00
Samy Pessé 67f78368fb Revert "Highlight code client-side" (#2796) 2025-01-30 20:09:57 +01:00
Claire Chabas 875b81b6b6 remove previously tested styles 2025-01-30 19:46:47 +01:00
Claire Chabas d3c7f4ae01 calc top value based on page 2025-01-30 19:35:49 +01:00
Claire Chabas 020269d562 Fix page outline positioning 2025-01-30 19:16:28 +01:00
41 changed files with 3099 additions and 608 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook-v2': minor
---
Setup structure and deployment for new version
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Improve performances by highlighting code client-side if the code block is offscreen
+149 -11
View File
@@ -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
View File
@@ -1,5 +1,6 @@
.next
.vercel
.open-next
# Generated
packages/emoji-codepoints/index.ts
+2058 -63
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -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",
+8
View File
@@ -0,0 +1,8 @@
# next.js
/.next/
# vercel
.vercel
# cloudflare
.open-next
+104
View File
@@ -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`
+5
View File
@@ -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.
+15
View File
@@ -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;
+30
View File
@@ -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;
+21
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export function getSiteCacheTag(siteId: string) {
return `sites/${siteId}`;
}
+112
View File
@@ -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();
}
+15
View File
@@ -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';
+11
View File
@@ -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();
}
+28
View File
@@ -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"]
}
+5
View File
@@ -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;
}
+46 -1
View File
@@ -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.
*/
+1 -1
View File
@@ -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
View File
@@ -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"],