Compare commits

..

20 Commits

Author SHA1 Message Date
Claire Chabas 6a705c6d3b testing memoized ctx 2025-01-13 13:35:13 +01:00
Claire Chabas 4ee22f767d testing 2025-01-13 12:36:37 +01:00
Claire Chabas 1accb6e28a testing separating render logic from fetching 2025-01-13 12:18:51 +01:00
Claire Chabas d4289c387c testing using ref for timeout 2025-01-13 12:10:22 +01:00
Claire Chabas 6ba70e649f testing removing getCtx from deps 2025-01-13 11:56:23 +01:00
Claire Chabas ee78e745d4 testing removing trackEven from dep list 2025-01-13 11:50:17 +01:00
Claire Chabas c0ec780a53 Merge branch 'main' into claire/fix-infinite-reload-search-ai
* main:
  Fix server actions stability (#2728)
  temporarily add back cloudflare KV cache backend (#2726)
  Fix padding of ads box (#2724)
  Try to fix error on og image generation (#2722)
2025-01-13 11:49:43 +01:00
Claire Chabas bffdef4370 back 2025-01-10 20:12:20 +01:00
Claire Chabas 848aaa808f update timeout to 1s 2025-01-10 20:04:33 +01:00
Claire Chabas 8401b1362f testing 2025-01-10 20:03:51 +01:00
Claire Chabas 063b05b1e1 testing 2025-01-10 19:58:28 +01:00
Claire Chabas 4df83559f7 testing 2025-01-10 19:48:01 +01:00
Claire Chabas e02c922464 testing 2025-01-10 19:47:33 +01:00
Claire Chabas 9fde9517b6 testing 2025-01-10 19:29:58 +01:00
Claire Chabas 3be3324329 testing 2025-01-10 19:20:55 +01:00
Claire Chabas cfac92add6 Update 2025-01-10 18:45:39 +01:00
Claire Chabas 1b849823b0 Testing 2025-01-10 18:40:18 +01:00
Claire Chabas c49f5862a4 Testing 2025-01-10 18:31:17 +01:00
Claire Chabas 14b3eafd0b format 2025-01-10 18:24:59 +01:00
Claire Chabas 776f8a8f5f Fix attempt 2025-01-10 18:23:39 +01:00
223 changed files with 2746 additions and 63335 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.1
1.1.40
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Support llms.txt
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Add support for keyboard marks
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': minor
---
Track new events for site insights when ads are being clicked
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Apply antialiasing on any text that are not code inline/blocks to avoid contrast issues
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': minor
---
Add in HTTP example code blocks
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix vertical section overflow color
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Reduce gap between subsequent header buttons
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Start using tint in more places, TOC and PageAside
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Remove KV cache backend and only rely on DO as an external cache backend
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix default outline list styling
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Cache API spec for 24 hours, revalidated every 2 hours
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Downgrade to Next.js v14 to fix incompatibilities with next-on-pages causing multiple bugs.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix multiple bugs due to headers read in an anarchic way in the app.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Update footer styling and allow for more than 4 footer groups
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix Image blocks zoomable behaviour
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix errors from customization not found
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Do not dereference before caching OpenAPI spec.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix regression issue with page icons for multi-line titles
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix OG image generation for non-latin characters
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Ignore cache invalidation error from local backend
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Fix an issue where a response object using a special ref would crash the page.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Allow only good values for theme query parameter. Avoid having a 500 error when we pass an invalid value.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Generate sitemap for all sections and spaces
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': minor
---
Fix an issue where search and Ask AI triggers unnecessary renders when in a Visitor Authenticated site.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Add feedback form to page rating control
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Update the routes for changes/revisions in multi-id mode to match the normal mode
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Lazy load iframely script to make page more responsive
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Cards stand out slighly more on tinted and dark mode sites, and have better support for headings inside them
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
New highlight colors
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix error when accessing a change request not found
-5
View File
@@ -1,5 +0,0 @@
---
'@gitbook/react-openapi': patch
---
Improve performances by loading Scalar API Client only when the button is clicked
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix issue leading to increase the storage write and the stability of the platform
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Disable C/C++ highlight temporarily
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/react-openapi': minor
'gitbook': minor
---
Fix internal properties appearing in OpenAPI docs.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': minor
---
Add antialiasing for text rendering
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Improve styling of header buttons with shadows and high-contrast styles
-8
View File
@@ -1,8 +0,0 @@
---
'@gitbook/react-openapi': major
---
Use `@scalar/openapi-parser` to be more resilient and perf on OpenAPI spec parsing:
- `fetcher.fetch` must now returns a valid OpenAPI document
- `parseOpenAPIV3` has been replaced by `parseOpenAPI`
-6
View File
@@ -1,6 +0,0 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI rendering performances by caching markdown parsing
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Improve performances by highlighting code client-side if the code block is offscreen
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix error when accessing some not found pages.
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix breadcrumbs emoji display + add contrast styles
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Fix variant dropdown styling in header
-5
View File
@@ -1,5 +0,0 @@
---
'gitbook': patch
---
Remove animation on section tabs. Page is reloaded (for technical reasons), so the animation is not accurate here.
+1 -1
View File
@@ -1 +1 @@
v20.8
v20.6
-3
View File
@@ -8,6 +8,3 @@ packages/icons/src/data/*.json
# Build files
dist/
README.md
CHANGELOG.md
+2 -4
View File
@@ -35,10 +35,8 @@ To run a local version of this project, please follow these simple steps.
### Prerequisites
- Node.js (Version: >=20.8)
- Use nvm for easy Node management
- Bun (Version: >=1.2.1)
- We use a text-based lockfile which isn't supported below 1.2.1
- Node.js (Version: >=18.x)
- Bun
### Set up
-3587
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
+3 -6
View File
@@ -4,20 +4,17 @@
"devDependencies": {
"@changesets/cli": "^2.27.7",
"prettier": "^3.0.3",
"turbo": "^2.3.4"
"turbo": "^2.1.2"
},
"packageManager": "bun@1.1.18",
"overrides": {
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1"
"@codemirror/state": "6.4.1"
},
"private": true,
"scripts": {
"dev": "turbo run dev --ui=stream",
"build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
@@ -36,6 +33,6 @@
],
"patchedDependencies": {
"decode-named-character-reference@1.0.2": "patches/decode-named-character-reference@1.0.2.patch",
"@vercel/next@4.4.2": "patches/@vercel%2Fnext@4.4.2.patch"
"@vercel/next@4.3.15": "patches/@vercel%2Fnext@4.3.15.patch"
}
}
+21 -192
View File
@@ -27,40 +27,12 @@ import { getContentTestURL } from '../tests/utils';
interface Test {
name: string;
/**
* URL to visit for testing.
*/
url: string;
url: string; // URL to visit for testing
cookies?: Parameters<BrowserContext['addCookies']>[0];
/**
* Test to run
*/
run?: (page: Page) => Promise<unknown>;
/**
* Whether the test should be fullscreened during testing.
*/
fullPage?: boolean;
/**
* Whether to take a screenshot of the test or set a threshold for the screenshot.
*/
screenshot?:
| false
| {
/**
* Screenshot threshold.
* From 0 to 1, where 0 is the most strict and 1 is the most permissive.
* @default 0.5
*/
threshold?: number;
/**
* Whether to wait for the table of contents to finish scrolling before taking the screenshot.
*/
waitForTOCScrolling?: boolean;
};
/**
* Whether to only run this test.
*/
only?: boolean;
run?: (page: Page) => Promise<unknown>; // The test to run
fullPage?: boolean; // Whether the test should be fullscreened during testing
screenshot?: false; // Should a screenshot be stored
only?: boolean; // Only run this test
}
interface TestsCase {
@@ -123,28 +95,19 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
@@ -305,28 +268,19 @@ const testCases: TestsCase[] = [
{
name: 'Search',
url: '?q=',
screenshot: false,
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
const allItems = await page.getByTestId('search-result-item').all();
// Expect at least 3 questions
await expect(allItems.length).toBeGreaterThan(2);
},
},
{
name: 'Search Results',
url: '?q=gitbook',
run: async (page) => {
await expect(page.getByTestId('search-results')).toBeVisible();
await page.waitForSelector('[data-test="search-results"]');
},
},
{
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
await page.waitForSelector('[data-test="search-ask-answer"]');
},
screenshot: false,
},
@@ -355,9 +309,6 @@ const testCases: TestsCase[] = [
{
name: 'PDF',
url: '~gitbook/pdf?limit=10',
screenshot: {
waitForTOCScrolling: false,
},
},
],
},
@@ -380,38 +331,11 @@ const testCases: TestsCase[] = [
url: 'blocks/block-images',
run: waitForCookiesDialog,
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Images (with zoom)',
url: 'blocks/block-images',
run: async (page) => {
await waitForCookiesDialog(page);
const zoomImage = page.getByTestId('zoom-image');
await zoomImage.first().click();
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
},
fullPage: true,
screenshot: { threshold: 0.8 },
},
{
name: 'Inline Images',
url: 'blocks/inline-images',
run: async (page) => {
await waitForCookiesDialog(page);
// Make the text invisible to fix flakiness due to the text position.
await page.evaluate(() => {
for (const p of document.querySelectorAll('p')) {
if (
p.textContent?.includes(
'This image has intrinsic 400px width, but renders as 300px:',
)
) {
p.style.color = 'transparent';
}
}
});
},
run: waitForCookiesDialog,
},
{
name: 'Tabs',
@@ -482,18 +406,6 @@ const testCases: TestsCase[] = [
{
name: 'Math',
url: 'blocks/math',
run: async (page) => {
await page.waitForFunction(() => {
const fonts = Array.from(document.fonts.values());
const mjxFonts = fonts.filter(
(font) => font.family === 'MJXZERO' || font.family === 'MJXTEX',
);
return (
mjxFonts.length === 2 &&
mjxFonts.every((font) => font.status === 'loaded')
);
});
},
},
{
name: 'Files',
@@ -547,9 +459,6 @@ const testCases: TestsCase[] = [
name: 'With cover and no TOC',
url: 'page-options/page-with-cover-and-no-toc',
run: waitForCookiesDialog,
screenshot: {
waitForTOCScrolling: false,
},
},
{
name: 'With icon',
@@ -733,14 +642,11 @@ const testCases: TestsCase[] = [
run: async (page) => {
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
expect(page.locator('h1')).toHaveText('shared');
const url = page.url();
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
],
},
@@ -752,15 +658,13 @@ const testCases: TestsCase[] = [
name: 'Navigation to shared space',
url: '',
run: async (page) => {
await page.locator('a.underline').click();
await expect(
page.getByRole('heading', { level: 1, name: 'shared' }),
).toBeVisible();
const sharedSpaceLink = page.locator('a.underline');
await sharedSpaceLink.click();
expect(page.locator('h1')).toHaveText('shared');
const url = page.url();
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
},
screenshot: false,
},
],
},
@@ -774,7 +678,6 @@ const testCases: TestsCase[] = [
run: async (page) => {
await expect(page.locator('h1')).toHaveText('SSO');
},
screenshot: false,
},
],
},
@@ -794,7 +697,6 @@ const testCases: TestsCase[] = [
page.getByText('Authentication missing to access this content'),
).toBeVisible();
},
screenshot: false,
},
],
},
@@ -817,12 +719,7 @@ const testCases: TestsCase[] = [
);
return `first?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
run: waitForCookiesDialog,
},
{
name: 'Second',
@@ -839,12 +736,7 @@ const testCases: TestsCase[] = [
);
return `second?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'second' }),
).toBeVisible();
},
screenshot: false,
run: waitForCookiesDialog,
},
],
},
@@ -958,12 +850,7 @@ const testCases: TestsCase[] = [
);
return `first?jwt_token=${token}`;
})(),
run: async (page) => {
await expect(
page.getByRole('heading', { level: 1, name: 'first' }),
).toBeVisible();
},
screenshot: false,
run: waitForCookiesDialog,
},
{
name: 'Custom page',
@@ -1459,24 +1346,16 @@ for (const testCase of testCases) {
if (testEntry.run) {
await testEntry.run(page);
}
const screenshotOptions = testEntry.screenshot;
if (screenshotOptions !== false) {
if (testEntry.screenshot !== false) {
await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, {
viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'],
viewports: ['macbook-16', 'macbook-13', 'iphone-x', 'ipad-2'],
argosCSS: `
/* Hide Intercom */
.intercom-lightweight-app {
display: none !important;
}
`,
threshold: screenshotOptions?.threshold ?? undefined,
}
`,
fullPage: testEntry.fullPage ?? false,
beforeScreenshot: async () => {
await waitForIcons(page);
if (screenshotOptions?.waitForTOCScrolling !== false) {
await waitForTOCScrolling(page);
}
},
});
}
});
@@ -1554,53 +1433,3 @@ function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettings>): s
return `?${searchParams.toString()}`;
}
/**
* Wait for all icons present on the page to be loaded.
*/
async function waitForIcons(page: Page) {
await page.waitForFunction(async () => {
function loadImage(src: string) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (error) => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
const icons = Array.from(document.querySelectorAll('svg.gb-icon'));
await Promise.all(
icons.map(async (icon) => {
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
throw new Error('No mask-image');
}
await loadImage(url);
}),
);
});
}
/**
* Wait for TOC to be correctly scrolled into view.
*/
async function waitForTOCScrolling(page: Page) {
const viewport = await page.viewportSize();
if (viewport && viewport.width >= 1024) {
const toc = page.getByTestId('table-of-contents');
await expect(toc).toBeVisible();
await page.evaluate(() => {
const tocScrollContainer = document.querySelector(
'[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]',
);
if (!tocScrollContainer) {
throw new Error('TOC scroll container not found');
}
tocScrollContainer.scrollTo(0, 0);
});
}
}
+7 -10
View File
@@ -16,7 +16,7 @@
"clean": "rm -rf ./.next && rm -rf ./public/~gitbook/static"
},
"dependencies": {
"@gitbook/api": "^0.89.0",
"@gitbook/api": "^0.85.0",
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
@@ -40,9 +40,8 @@
"jsontoxml": "^1.0.1",
"katex": "^0.16.9",
"mathjax": "^3.2.2",
"mdast-util-to-markdown": "^2.1.2",
"memoizee": "^0.4.15",
"next": "14.2.23",
"next": "15.1.3",
"next-themes": "^0.2.1",
"nuqs": "^2.2.3",
"object-hash": "^3.0.0",
@@ -50,8 +49,8 @@
"p-map": "^7.0.0",
"parse-cache-control": "^1.0.1",
"postcss-color-contrast": "^1.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hotkeys-hook": "^4.4.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
@@ -60,7 +59,7 @@
"remark-rehype": "^11.1.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"shiki": "^1.27.2",
"shiki": "^1.26.1",
"tailwind-merge": "^2.2.0",
"tailwind-shades": "^1.1.2",
"unified": "^11.0.4",
@@ -68,15 +67,13 @@
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@argos-ci/playwright": "^3.10.0",
"@argos-ci/playwright": "^2.0.0",
"@cloudflare/next-on-pages": "1.13.7",
"vercel": "^39.3.0",
"@cloudflare/workers-types": "^4.20241230.0",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.42.1",
"@types/js-cookie": "^3.0.6",
"@types/jsontoxml": "^1.0.5",
"@types/jsonwebtoken": "^9.0.6",
"@types/mdast": "^4.0.4",
"@types/node": "^20",
"@types/object-hash": "^3.0.6",
"@types/parse-cache-control": "^1.0.4",
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import {
CURRENT_SIGNATURE_VERSION,
isSignatureVersion,
@@ -39,7 +40,8 @@ export async function GET(request: NextRequest) {
}
// Verify the signature
const verified = await verifyImageSignature(url, { signature, version: signatureVersion });
const ctx = getGitBookContextFromHeaders(request.headers);
const verified = await verifyImageSignature(ctx, url, { signature, version: signatureVersion });
if (!verified) {
return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 });
}
@@ -1,14 +1,18 @@
import { headers } from 'next/headers';
import { TrackPageViewEvent } from '@/components/Insights';
import { getSpaceLanguage, t } from '@/intl/server';
import { getSiteData, getSpaceContentData } from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
export default async function NotFound() {
const pointer = await getSiteContentPointer();
const ctx = getGitBookContextFromHeaders(await headers());
const pointer = getSiteContentPointer(ctx);
const [{ space }, { customization }] = await Promise.all([
getSpaceContentData(pointer, pointer.siteShareKey),
getSiteData(pointer),
getSpaceContentData(ctx, pointer, pointer.siteShareKey),
getSiteData(ctx, pointer),
]);
const language = getSpaceLanguage(customization);
@@ -1,10 +1,12 @@
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context';
import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links';
import { getPagePath, resolveFirstDocument } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
@@ -17,17 +19,21 @@ import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } fr
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
type Props = {
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
};
/**
* Fetch and render a page.
*/
export default async function Page(props: {
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
}) {
const { params: rawParams, searchParams: rawSearchParams } = props;
const params = await rawParams;
const searchParams = await rawSearchParams;
export default async function Page(props: Props) {
const [headersList, params, searchParams] = await Promise.all([
headers(),
props.params,
props.searchParams,
]);
const ctx = getGitBookContextFromHeaders(headersList);
const {
content: contentPointer,
@@ -40,7 +46,7 @@ export default async function Page(props: {
page,
ancestors,
document,
} = await getPageDataWithFallback({
} = await getPageDataWithFallback(ctx, {
pagePathParams: params,
searchParams,
redirectOnFallback: true,
@@ -53,12 +59,12 @@ export default async function Page(props: {
if (pathname !== rawPathname) {
// If the pathname was not normalized, redirect to the normalized version
// before trying to resolve the page again
redirect(await getAbsoluteHref(pathname));
redirect(getAbsoluteHref(ctx, pathname));
} else {
notFound();
}
} else if (getPagePath(pages, page) !== rawPathname) {
redirect(await getPageHref(pages, page, linksContext));
redirect(getPageHref(ctx, pages, page, linksContext));
}
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -109,7 +115,10 @@ export default async function Page(props: {
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
/>
</div>
<React.Suspense fallback={null}>
@@ -119,12 +128,10 @@ export default async function Page(props: {
);
}
export async function generateViewport({
params,
}: {
params: Promise<PagePathParams>;
}): Promise<Viewport> {
const { customization } = await fetchPageData(await params);
export async function generateViewport(props: Props): Promise<Viewport> {
const [params, headersList] = await Promise.all([props.params, headers()]);
const ctx = getGitBookContextFromHeaders(headersList);
const { customization } = await fetchPageData(ctx, params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
@@ -134,17 +141,20 @@ export async function generateViewport({
};
}
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<PagePathParams>;
searchParams: Promise<{ fallback?: string }>;
}): Promise<Metadata> {
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({
pagePathParams: await params,
searchParams: await searchParams,
});
export async function generateMetadata(props: Props): Promise<Metadata> {
const [params, searchParams, headersList] = await Promise.all([
props.params,
props.searchParams,
headers(),
]);
const ctx = getGitBookContextFromHeaders(headersList);
const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback(
ctx,
{
pagePathParams: params,
searchParams: searchParams,
},
);
if (!page) {
notFound();
@@ -157,17 +167,16 @@ export async function generateMetadata({
description: page.description ?? '',
alternates: {
// Trim trailing slashes in canonical URL to match the redirect behavior
canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''),
canonical: getAbsoluteHref(ctx, getPagePath(pages, page), true).replace(/\/+$/, ''),
},
openGraph: {
images: [
customization.socialPreview.url ??
(await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)),
getAbsoluteHref(ctx, `~gitbook/ogimage/${page.id}`, true),
],
},
robots:
(await isSpaceIndexable({ space, site: site ?? null })) &&
isPageIndexable(ancestors, page)
isSpaceIndexable(ctx, { space, site: site ?? null }) && isPageIndexable(ancestors, page)
? 'index, follow'
: 'noindex, nofollow',
};
@@ -176,14 +185,17 @@ export async function generateMetadata({
/**
* Fetches the page data matching the requested pathname and fallback to root page when page is not found.
*/
async function getPageDataWithFallback(args: {
pagePathParams: PagePathParams;
searchParams: { fallback?: string };
redirectOnFallback?: boolean;
}) {
async function getPageDataWithFallback(
ctx: GitBookContext,
args: {
pagePathParams: PagePathParams;
searchParams: { fallback?: string };
redirectOnFallback?: boolean;
},
) {
const { pagePathParams, searchParams, redirectOnFallback = false } = args;
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams);
const { pages, page: targetPage, ...otherPageData } = await fetchPageData(ctx, pagePathParams);
let page = targetPage;
const canFallback = !!searchParams.fallback;
@@ -191,7 +203,7 @@ async function getPageDataWithFallback(args: {
const rootPage = resolveFirstDocument(pages, []);
if (redirectOnFallback && rootPage?.page) {
redirect(await getPageHref(pages, rootPage?.page));
redirect(getPageHref(ctx, pages, rootPage?.page));
}
page = rootPage?.page;
@@ -13,6 +13,7 @@ import { api } from '@/lib/api';
import { assetsDomain } from '@/lib/assets';
import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { GitBookContext, getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getAbsoluteHref, getBaseUrl } from '@/lib/links';
import { isSpaceIndexable } from '@/lib/seo';
import { getContentTitle } from '@/lib/utils';
@@ -28,9 +29,10 @@ export const dynamic = 'force-dynamic';
* Layout when rendering the content.
*/
export default async function ContentLayout(props: { children: React.ReactNode }) {
const ctx = getGitBookContextFromHeaders(await headers());
const { children } = props;
const nonce = await getContentSecurityPolicyNonce();
const nonce = getContentSecurityPolicyNonce(ctx);
const {
content,
space,
@@ -42,10 +44,9 @@ export default async function ContentLayout(props: { children: React.ReactNode }
ancestors,
scripts,
sections,
} = await fetchContentData();
} = await fetchContentData(ctx);
const apiCtx = await api();
ReactDOM.preconnect(apiCtx.client.endpoint);
ReactDOM.preconnect(api(ctx).client.endpoint);
if (assetsDomain) {
ReactDOM.preconnect(assetsDomain);
}
@@ -57,7 +58,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
});
});
const queryStringTheme = await getQueryStringTheme();
const queryStringTheme = getQueryStringTheme(ctx);
return (
<NuqsAdapter>
@@ -106,7 +107,8 @@ export default async function ContentLayout(props: { children: React.ReactNode }
}
export async function generateViewport(): Promise<Viewport> {
const { customization } = await fetchContentData();
const ctx = getGitBookContextFromHeaders(await headers());
const { customization } = await fetchContentData(ctx);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
@@ -117,46 +119,43 @@ export async function generateViewport(): Promise<Viewport> {
}
export async function generateMetadata(): Promise<Metadata> {
const { space, site, customization } = await fetchContentData();
const ctx = getGitBookContextFromHeaders(await headers());
const { space, site, customization } = await fetchContentData(ctx);
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return {
title: getContentTitle(space, customization, site),
generator: `GitBook (${buildVersion()})`,
metadataBase: new URL(await getBaseUrl()),
metadataBase: new URL(getBaseUrl(ctx)),
icons: {
icon: [
{
url:
customIcon?.light ??
(await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)),
getAbsoluteHref(ctx, '~gitbook/icon?size=small&theme=light', true),
type: 'image/png',
media: '(prefers-color-scheme: light)',
},
{
url:
customIcon?.dark ??
(await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)),
getAbsoluteHref(ctx, '~gitbook/icon?size=small&theme=dark', true),
type: 'image/png',
media: '(prefers-color-scheme: dark)',
},
],
},
robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow',
robots: isSpaceIndexable(ctx, { space, site }) ? 'index, follow' : 'noindex, nofollow',
};
}
/**
* For preview, the theme can be set via query string (?theme=light).
*/
async function getQueryStringTheme() {
const headersList = await headers();
const queryStringTheme = headersList.get('x-gitbook-theme');
if (!queryStringTheme) {
function getQueryStringTheme(ctx: GitBookContext) {
if (!ctx.theme) {
return null;
}
return queryStringTheme === 'light'
? CustomizationThemeMode.Light
: CustomizationThemeMode.Dark;
return ctx.theme === 'light' ? CustomizationThemeMode.Light : CustomizationThemeMode.Dark;
}
@@ -1,6 +1,7 @@
import { NextRequest } from 'next/server';
import { getSpace, getSite } from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getAbsoluteHref } from '@/lib/links';
import { getSiteContentPointer } from '@/lib/pointer';
import { isSpaceIndexable } from '@/lib/seo';
@@ -11,17 +12,18 @@ export const runtime = 'edge';
* Generate a robots.txt for the current space.
*/
export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer();
const ctx = getGitBookContextFromHeaders(req.headers);
const pointer = getSiteContentPointer(ctx);
const [site, space] = await Promise.all([
getSite(pointer.organizationId, pointer.siteId),
getSpace(pointer.spaceId, pointer.siteShareKey),
getSite(ctx, pointer.organizationId, pointer.siteId),
getSpace(ctx, pointer.spaceId, pointer.siteShareKey),
]);
const lines = [
`User-agent: *`,
'Disallow: /~gitbook/',
...((await isSpaceIndexable({ space, site }))
? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`]
...(isSpaceIndexable(ctx, { space, site })
? [`Allow: /`, `Sitemap: ${getAbsoluteHref(ctx, `/sitemap.xml`, true)}`]
: [`Disallow: /`]),
];
const content = lines.join('\n');
@@ -0,0 +1,97 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import jsontoxml from 'jsontoxml';
import { NextRequest } from 'next/server';
import { getSpaceContentData } from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { isPageIndexable } from '@/lib/seo';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current space.
*/
export async function GET(req: NextRequest) {
const ctx = getGitBookContextFromHeaders(req.headers);
const pointer = getSiteContentPointer(ctx);
const { pages: rootPages } = await getSpaceContentData(ctx, pointer, pointer.siteShareKey);
const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page));
const urls = pages.map(({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const lastModified = page.updatedAt || page.createdAt;
return {
url: {
loc: getAbsoluteHref(ctx, getPagePath(rootPages, page), true),
priority: normalizedPriority,
...(lastModified
? {
// lastmod format is YYYY-MM-DD
lastmod: new Date(lastModified).toISOString().split('T')[0],
}
: {}),
},
};
});
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
'xsi:schemaLocation':
'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
type FlatPageEntry = { page: RevisionPageDocument; depth: number };
function flattenPages(
rootPags: RevisionPage[],
filter: (page: RevisionPageDocument | RevisionPageGroup) => boolean,
): FlatPageEntry[] {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
): FlatPageEntry[] => {
const allowed = filter(page);
if (!allowed) {
return [];
}
return [
...(page.type === 'document' ? [{ page, depth }] : []),
...page.pages.flatMap((child) =>
child.type === 'document' ? flattenPage(child, depth + 1) : [],
),
];
};
return rootPags.flatMap((page) =>
page.type === 'group' || page.type === 'document' ? flattenPage(page, 0) : [],
);
}
@@ -5,6 +5,7 @@ import React from 'react';
import { getSite, getSiteData, getSpace } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getSiteContentPointer } from '@/lib/pointer';
import { tcls } from '@/lib/tailwind';
import { getContentTitle } from '@/lib/utils';
@@ -32,17 +33,18 @@ const SIZES = {
* Render an icon for the space.
*/
export async function GET(req: NextRequest) {
const ctx = getGitBookContextFromHeaders(req.headers);
const options = getOptions(req.url);
const size = SIZES[options.size];
const pointer = await getSiteContentPointer();
const pointer = getSiteContentPointer(ctx);
const spaceId = pointer.spaceId;
const [space, { customization }] = await Promise.all([
getSpace(spaceId, pointer.siteShareKey),
getSiteData(pointer),
getSpace(ctx, spaceId, pointer.siteShareKey),
getSiteData(ctx, pointer),
]);
const site = await getSite(pointer.organizationId, pointer.siteId);
const site = await getSite(ctx, pointer.organizationId, pointer.siteId);
const contentTitle = getContentTitle(space, customization, site);
return new ImageResponse(
@@ -6,6 +6,7 @@ import colorContrast from 'postcss-color-contrast/js';
import React from 'react';
import { googleFontsMap } from '@/fonts';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getAbsoluteHref } from '@/lib/links';
import { filterOutNullable } from '@/lib/typescript';
import { getContentTitle } from '@/lib/utils';
@@ -14,49 +15,27 @@ import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge';
async function loadGoogleFont(input: { fontFamily: string; text: string; weight: 400 | 700 }) {
const { fontFamily, text, weight } = input;
if (!text.trim()) {
return null;
}
const url = new URL('https://fonts.googleapis.com/css2');
url.searchParams.set('family', `${fontFamily}:wght@${weight}`);
url.searchParams.set('text', text);
const result = await fetch(url.href);
if (!result.ok) {
return null;
}
const css = await result.text();
async function loadGoogleFont(font: string, text: string) {
const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`;
const css = await (await fetch(url)).text();
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/);
const resourceUrl = resource ? resource[1] : null;
if (resourceUrl) {
const response = await fetch(resourceUrl);
if (response.ok) {
const data = await response.arrayBuffer();
return {
name: fontFamily,
data,
style: 'normal' as const,
weight,
};
if (resource) {
const response = await fetch(resource[1]);
if (response.status == 200) {
return await response.arrayBuffer();
}
}
// If for some reason we can't load the font, we'll just use the default one
return null;
throw new Error('failed to load font data');
}
/**
* Render the OpenGraph image for a space.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<PageIdParams> }) {
const { space, page, customization, site } = await fetchPageData(await params);
const ctx = getGitBookContextFromHeaders(req.headers);
const { space, page, customization, site } = await fetchPageData(ctx, await params);
// If user configured a custom social preview, we redirect to it.
if (customization.socialPreview.url) {
@@ -86,8 +65,24 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
const fonts = (
await Promise.all([
loadGoogleFont({ fontFamily, text: regularText, weight: 400 }),
loadGoogleFont({ fontFamily, text: boldText, weight: 700 }),
regularText
? loadGoogleFont(`${fontFamily}:wght@400`, regularText).then((data) => ({
name: fontFamily,
data,
style: 'normal' as const,
weight: 400 as const,
}))
: null,
boldText
? loadGoogleFont(`${fontFamily}:wght@700`, `${contentTitle}${pageTitle}`).then(
(data) => ({
name: fontFamily,
data,
style: 'normal' as const,
weight: 700 as const,
}),
)
: null,
])
).filter(filterOutNullable);
@@ -104,10 +99,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
body: baseColors[useLightTheme ? 'dark' : 'light'], // Invert text on background
};
const [gridWhite, gridBlack] = await Promise.all([
getAbsoluteHref('~gitbook/static/images/ogimage-grid-white.png', true),
getAbsoluteHref('~gitbook/static/images/ogimage-grid-black.png', true),
]);
const gridWhite = getAbsoluteHref(ctx, '~gitbook/static/images/ogimage-grid-white.png', true);
const gridBlack = getAbsoluteHref(ctx, '~gitbook/static/images/ogimage-grid-black.png', true);
let gridAsset = useLightTheme ? gridBlack : gridWhite;
@@ -145,30 +138,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
break;
}
const favicon = await (async () => {
if ('icon' in customization.favicon)
return (
<img
src={customization.favicon.icon[theme]}
width={40}
height={40}
tw="mr-4"
alt="Icon"
/>
);
if ('emoji' in customization.favicon)
return (
<span tw="text-4xl mr-4">
{String.fromCodePoint(parseInt('0x' + customization.favicon.emoji))}
</span>
);
const src = await getAbsoluteHref(
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
);
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
})();
return new ImageResponse(
(
<div
@@ -202,7 +171,32 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
/>
) : (
<div tw="flex">
{favicon}
{(() => {
if ('icon' in customization.favicon)
return (
<img
src={customization.favicon.icon[theme]}
width={40}
height={40}
tw="mr-4"
alt="Icon"
/>
);
if ('emoji' in customization.favicon)
return (
<span tw="text-4xl mr-4">
{String.fromCodePoint(
parseInt('0x' + customization.favicon.emoji),
)}
</span>
);
const src = getAbsoluteHref(
ctx,
`~gitbook/icon?size=medium&theme=${customization.themes.default}`,
true,
);
return <img src={src} alt="Icon" width={40} height={40} tw="mr-4" />;
})()}
<h3 tw="text-4xl my-0 font-bold">{contentTitle}</h3>
</div>
)}
@@ -210,7 +204,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
{/* Title and description */}
<div tw="flex flex-col">
<h1
tw={`text-8xl my-0 tracking-tight leading-none text-left text-[${colors.title}] font-bold`}
tw={`text-8xl my-0 tracking-light leading-none text-left text-[${colors.title}] font-bold`}
>
{pageTitle}
</h1>
@@ -223,7 +217,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<PageId
{
width: 1200,
height: 630,
fonts: fonts.length ? fonts : undefined,
fonts,
},
);
}
@@ -8,6 +8,7 @@ import {
getSiteData,
getSiteRedirectBySource,
} from '@/lib/api';
import { GitBookContext } from '@/lib/gitbook-context';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
@@ -22,13 +23,13 @@ export interface PageIdParams {
/**
* Fetch all the data needed to render the content layout.
*/
export async function fetchContentData() {
const content = await getSiteContentPointer();
export async function fetchContentData(ctx: GitBookContext) {
const content = getSiteContentPointer(ctx);
const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] =
await Promise.all([
getSpaceContentData(content, content.siteShareKey),
getSiteData(content),
getSpaceContentData(ctx, content, content.siteShareKey),
getSiteData(ctx, content),
]);
// we grab the space attached to the parent as it contains overriden customizations
@@ -53,10 +54,10 @@ export async function fetchContentData() {
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
*/
export async function fetchPageData(params: PagePathParams | PageIdParams) {
const contentData = await fetchContentData();
export async function fetchPageData(ctx: GitBookContext, params: PagePathParams | PageIdParams) {
const contentData = await fetchContentData(ctx);
const page = await resolvePage({
const page = await resolvePage(ctx, {
organizationId: contentData.space.organization,
siteId: contentData.site.id,
spaceId: contentData.contentTarget.spaceId,
@@ -66,7 +67,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
params,
});
const document = page?.page.documentId
? await getDocument(contentData.space.id, page.page.documentId)
? await getDocument(ctx, contentData.space.id, page.page.documentId)
: null;
return {
@@ -80,15 +81,18 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
* Resolve a page from the params.
* If the path can't be found, we try to resolve it from the API to handle redirects.
*/
async function resolvePage(input: {
organizationId: string;
siteId: string;
spaceId: string;
revisionId: string;
shareKey: string | undefined;
pages: RevisionPage[];
params: PagePathParams | PageIdParams;
}) {
async function resolvePage(
ctx: GitBookContext,
input: {
organizationId: string;
siteId: string;
spaceId: string;
revisionId: string;
shareKey: string | undefined;
pages: RevisionPage[];
params: PagePathParams | PageIdParams;
},
) {
const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input;
if ('pageId' in params) {
@@ -109,13 +113,13 @@ async function resolvePage(input: {
// If page can't be found, we try with the API, in case we have a redirect at space level.
// We use the raw pathname to handle special/malformed redirects setup by users in the GitSync.
// The page rendering will take care of redirecting to a normalized pathname.
const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname);
const resolved = await getRevisionPageByPath(ctx, spaceId, revisionId, rawPathname);
if (resolved) {
return resolvePageId(pages, resolved.id);
}
// If a page still can't be found, we try with the API, in case we have a redirect at site level.
const resolvedSiteRedirect = await getSiteRedirectBySource({
const resolvedSiteRedirect = await getSiteRedirectBySource(ctx, {
organizationId,
siteId,
source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`,
@@ -1,5 +1,8 @@
import { headers } from 'next/headers';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteData } from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getSiteContentPointer } from '@/lib/pointer';
/**
@@ -8,9 +11,10 @@ import { getSiteContentPointer } from '@/lib/pointer';
*/
export default async function SiteRootLayout(props: { children: React.ReactNode }) {
const { children } = props;
const ctx = getGitBookContextFromHeaders(await headers());
const pointer = await getSiteContentPointer();
const { customization } = await getSiteData(pointer);
const pointer = getSiteContentPointer(ctx);
const { customization } = await getSiteData(ctx, pointer);
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -1,7 +1,9 @@
import { SpaceIntegrationScript } from '@gitbook/api';
import { headers } from 'next/headers';
import { CustomizationRootLayout } from '@/components/RootLayout';
import { getSiteData, getSpaceCustomization } from '@/lib/api';
import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context';
import { getSiteOrSpacePointerForPDF } from './pointer';
@@ -10,12 +12,13 @@ import { getSiteOrSpacePointerForPDF } from './pointer';
* site or space and initializes the CustomizationRootLayout with it.
*/
export default async function PDFRootLayout(props: { children: React.ReactNode }) {
const ctx = getGitBookContextFromHeaders(await headers());
const { children } = props;
const pointer = await getSiteOrSpacePointerForPDF();
const pointer = getSiteOrSpacePointerForPDF(ctx);
const { customization } = await ('siteId' in pointer
? getSiteData(pointer)
: getSpaceLayoutData());
? getSiteData(ctx, pointer)
: getSpaceLayoutData(ctx));
return (
<CustomizationRootLayout customization={customization}>{children}</CustomizationRootLayout>
@@ -25,14 +28,11 @@ export default async function PDFRootLayout(props: { children: React.ReactNode }
/**
* Fetch all the layout data about a space at once.
*/
async function getSpaceLayoutData() {
const [{ customization }, scripts] = await Promise.all([
getSpaceCustomization(),
[] as SpaceIntegrationScript[],
]);
async function getSpaceLayoutData(ctx: GitBookContext) {
const { customization } = await getSpaceCustomization(ctx);
return {
customization,
scripts,
scripts: [] as SpaceIntegrationScript[],
};
}
@@ -4,11 +4,11 @@ import {
RevisionPageDocument,
RevisionPageGroup,
SiteCustomizationSettings,
SiteInsightsTrademarkPlacement,
Space,
} from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { Metadata } from 'next';
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import * as React from 'react';
@@ -24,6 +24,7 @@ import {
getSpaceContentData,
getSiteData,
} from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
@@ -41,10 +42,11 @@ export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function generateMetadata(): Promise<Metadata> {
const pointer = await getSiteOrSpacePointerForPDF();
const ctx = getGitBookContextFromHeaders(await headers());
const pointer = getSiteOrSpacePointerForPDF(ctx);
const [space, { customization }] = await Promise.all([
getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
getSpace(ctx, pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(ctx, pointer) : getSpaceCustomization(ctx),
]);
return {
@@ -59,19 +61,20 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function PDFHTMLOutput(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const pointer = await getSiteOrSpacePointerForPDF();
const ctx = getGitBookContextFromHeaders(await headers());
const pointer = getSiteOrSpacePointerForPDF(ctx);
const searchParams = new URLSearchParams(await props.searchParams);
const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams));
// Build current PDF URL and preserve all search params
let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true);
let currentPDFUrl = getAbsoluteHref(ctx, '~gitbook/pdf', true);
currentPDFUrl += '?' + searchParams.toString();
// Load the content,
const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([
'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(),
getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
'siteId' in pointer ? getSiteData(ctx, pointer) : getSpaceCustomization(ctx),
getSpaceContentData(ctx, pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined),
]);
const language = getSpaceLanguage(customization);
@@ -90,7 +93,7 @@ export default async function PDFHTMLOutput(props: {
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden', 'z-50')}>
<a
title={tString(language, 'pdf_goback')}
href={pdfParams.back ?? (await getAbsoluteHref(''))}
href={pdfParams.back ?? getAbsoluteHref(ctx, '')}
className={tcls(
'flex',
'flex-row',
@@ -147,11 +150,7 @@ export default async function PDFHTMLOutput(props: {
limit={pdfParams.limit ?? DEFAULT_LIMIT}
trademark={
customization.trademark.enabled ? (
<TrademarkLink
space={space}
customization={customization}
placement={SiteInsightsTrademarkPlacement.Footer}
/>
<TrademarkLink space={space} customization={customization} />
) : null
}
/>
@@ -232,9 +231,10 @@ async function PDFPageDocument(props: {
page: RevisionPageDocument;
refContext: ContentRefContext;
}) {
const ctx = getGitBookContextFromHeaders(await headers());
const { space, page, refContext } = props;
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
const document = page.documentId ? await getDocument(ctx, space.id, page.documentId) : null;
return (
<PrintPage id={getPagePDFContainerId(page)}>
@@ -255,7 +255,7 @@ async function PDFPageDocument(props: {
revisionId: refContext.revisionId,
},
contentRefContext: refContext,
resolveContentRef: (ref) => resolveContentRef(ref, refContext),
resolveContentRef: (ref) => resolveContentRef(ctx, ref, refContext),
getId: (id) => getPagePDFContainerId(page, id),
}}
/>
@@ -1,4 +1,5 @@
import { SiteContentPointer, SpaceContentPointer } from '@/lib/api';
import { GitBookContext } from '@/lib/gitbook-context';
import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
/**
@@ -8,12 +9,12 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer';
*
* This function returns the pointer depending on the context.
*/
export async function getSiteOrSpacePointerForPDF(): Promise<
SiteContentPointer | SpaceContentPointer
> {
export function getSiteOrSpacePointerForPDF(
ctx: GitBookContext,
): SiteContentPointer | SpaceContentPointer {
try {
return await getSiteContentPointer();
return getSiteContentPointer(ctx);
} catch (error) {
return getSpacePointer();
return getSpacePointer(ctx);
}
}
@@ -1,153 +0,0 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import { Heading, ListItem, Paragraph, Root, RootContent } from 'mdast';
import { toMarkdown } from 'mdast-util-to-markdown';
import { NextRequest } from 'next/server';
import { getPublishedContentSite, getRevisionPages } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
export const runtime = 'edge';
/**
* Generate a llms.txt file for the site.
*/
export async function GET(req: NextRequest) {
const pointer = await getSiteContentPointer();
const { structure: siteStructure, site } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('llms.txt is only served from the root of the site', { status: 404 });
}
const tree: Root = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: site.title }],
},
...(await getNodesFromSiteStructure(siteStructure)),
],
};
return new Response(
toMarkdown(tree, {
bullet: '-',
}),
{
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
},
);
}
/**
* Get MDAST nodes from site structure.
*/
async function getNodesFromSiteStructure(siteStructure: SiteStructure): Promise<RootContent[]> {
switch (siteStructure.type) {
case 'sections':
return getNodesFromSections(siteStructure.structure);
case 'siteSpaces':
return getNodesFromSiteSpaces(siteStructure.structure, { heading: true });
default:
assertNever(siteStructure);
}
}
/**
* Get MDAST nodes from site sections.
*/
async function getNodesFromSections(siteSections: SiteSection[]): Promise<RootContent[]> {
const all = await Promise.all(
siteSections.map(async (siteSection): Promise<RootContent[]> => {
const siteSpaceNodes = await getNodesFromSiteSpaces(siteSection.siteSpaces, {
heading: false,
});
return [
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSection.title }],
},
...siteSpaceNodes,
];
}),
);
return all.flat();
}
/**
* Get MDAST nodes from site spaces.
*/
async function getNodesFromSiteSpaces(
siteSpaces: SiteSpace[],
options: {
/**
* Includes a heading for each site space.
*/
heading?: boolean;
},
): Promise<RootContent[]> {
const all = await Promise.all(
siteSpaces.map(async (siteSpace): Promise<RootContent[]> => {
const siteSpaceUrl = siteSpace.urls.published;
if (!siteSpaceUrl) {
return [];
}
const rootPages = await getRevisionPages(siteSpace.space.id, siteSpace.space.revision, {
metadata: false,
});
const pages = getIndexablePages(rootPages);
const listChildren = await Promise.all(
pages.map(async ({ page }): Promise<ListItem> => {
const url = await getAbsoluteHref(
joinPath(new URL(siteSpaceUrl).pathname, getPagePath(rootPages, page)),
true,
);
const children: Paragraph['children'] = [
{
type: 'link',
url,
children: [{ type: 'text', value: page.title }],
},
];
if (page.description) {
children.push({ type: 'text', value: `: ${page.description}` });
}
return {
type: 'listItem',
children: [{ type: 'paragraph', children }],
};
}),
);
const nodes: RootContent[] = [];
if (options.heading) {
nodes.push({
type: 'heading',
depth: 2,
children: [{ type: 'text', value: siteSpace.title }],
});
}
nodes.push({
type: 'list',
spread: false,
children: listChildren,
});
return nodes;
}),
);
return all.flat();
}
@@ -1,66 +0,0 @@
import jsontoxml from 'jsontoxml';
import { getRevisionPages, getSpace } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
import { getIndexablePages } from '@/lib/sitemap';
export const runtime = 'edge';
/**
* Generate a sitemap.xml for the current section / space.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const revisionId =
pointer.revisionId ?? (await getSpace(pointer.spaceId, pointer.siteShareKey)).revision;
const rootPages = await getRevisionPages(pointer.spaceId, revisionId, { metadata: false });
const pages = getIndexablePages(rootPages);
const urls = await Promise.all(
pages.map(async ({ page, depth }) => {
// Decay priority with depth
const priority = Math.pow(2, -0.25 * depth);
// Normalize to keep 2 decimals
const normalizedPriority = Math.floor(100 * priority) / 100;
const lastModified = page.updatedAt || page.createdAt;
const url: { loc: string; priority: number; lastmod?: string } = {
priority: normalizedPriority,
loc: await getAbsoluteHref(getPagePath(rootPages, page), true),
};
if (lastModified) {
url.lastmod = new Date(lastModified).toISOString();
}
return { url };
}),
);
const xml = jsontoxml(
[
{
name: 'urlset',
children: urls,
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
@@ -1,98 +0,0 @@
import { SiteSection, SiteSpace, SiteStructure } from '@gitbook/api';
import assertNever from 'assert-never';
import jsontoxml from 'jsontoxml';
import { getPublishedContentSite, SiteContentPointer } from '@/lib/api';
import { getAbsoluteHref } from '@/lib/links';
import { joinPath } from '@/lib/paths';
import { checkIsRootPointer, getSiteContentPointer } from '@/lib/pointer';
import { filterOutNullable } from '@/lib/typescript';
export const runtime = 'edge';
/**
* Generate a root sitemap that point to all sitemap-pages.xml.
*/
export async function GET() {
const pointer = await getSiteContentPointer();
const { structure: siteStructure } = await getPublishedContentSite({
organizationId: pointer.organizationId,
siteId: pointer.siteId,
siteShareKey: pointer.siteShareKey,
});
if (!checkIsRootPointer(pointer, siteStructure)) {
return new Response('Root sitemap is only served from the root of the site', {
status: 404,
});
}
const urls = await getUrlsFromSiteStructure(siteStructure);
const xml = jsontoxml(
[
{
name: 'sitemapindex',
children: urls.map((url) => ({
name: 'sitemap',
children: [{ name: 'loc', text: url }],
})),
attrs: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
},
},
],
{
xmlHeader: true,
prettyPrint: true,
},
);
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
},
});
}
/**
* Get Sitemap URLs from site structure.
*/
async function getUrlsFromSiteStructure(siteStructure: SiteStructure): Promise<string[]> {
switch (siteStructure.type) {
case 'sections':
return getUrlsFromSiteSections(siteStructure.structure);
case 'siteSpaces':
return getUrlsFromSiteSpaces(siteStructure.structure);
default:
assertNever(siteStructure);
}
}
/**
* Get Sitemap URLs from site sections.
*/
async function getUrlsFromSiteSections(siteSections: SiteSection[]): Promise<string[]> {
const urls = await Promise.all(
siteSections.map(async (siteSection) => getUrlsFromSiteSpaces(siteSection.siteSpaces), []),
);
return urls.flat();
}
/**
* Get Sitemap URLs from site spaces.
*/
async function getUrlsFromSiteSpaces(siteSpaces: SiteSpace[]): Promise<string[]> {
const urls = await Promise.all(
siteSpaces.map(async (siteSpace) => {
if (!siteSpace.urls.published) {
return null;
}
const url = new URL(siteSpace.urls.published);
url.pathname = joinPath(url.pathname, 'sitemap-pages.xml');
return url.toString();
}, []),
);
return urls.filter(filterOutNullable);
}
@@ -4,6 +4,7 @@ import { headers } from 'next/headers';
import React from 'react';
import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { tcls } from '@/lib/tailwind';
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
@@ -15,7 +16,33 @@ interface AdminToolbarProps {
space: Space;
}
function ToolbarLayout(props: { children: React.ReactNode }) {
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const toolbar = (() => {
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
spaceId={content.spaceId}
changeRequestId={content.changeRequestId}
/>
);
}
if (content.revisionId) {
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
}
return null;
})();
if (!toolbar) {
return null;
}
return (
<div
className={tcls(
@@ -36,115 +63,80 @@ function ToolbarLayout(props: { children: React.ReactNode }) {
'backdrop-blur-sm',
)}
>
<React.Suspense fallback={null}>{props.children}</React.Suspense>
<React.Suspense fallback={null}>{toolbar}</React.Suspense>
</div>
);
}
/**
* Toolbar with information for the content admin when previewing a revision or change-request.
*/
export async function AdminToolbar(props: AdminToolbarProps) {
const { content } = props;
const mode = await headers().get('x-gitbook-mode');
if (mode === 'multi-id') {
// We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard.
return null;
}
if (content.changeRequestId) {
return (
<ChangeRequestToolbar
spaceId={content.spaceId}
changeRequestId={content.changeRequestId}
/>
);
}
if (content.revisionId) {
return <RevisionToolbar spaceId={content.spaceId} revisionId={content.revisionId} />;
}
return null;
}
async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) {
const { spaceId, changeRequestId } = props;
const ctx = getGitBookContextFromHeaders(await headers());
const changeRequest = await getChangeRequest(spaceId, changeRequestId);
if (!changeRequest) {
return null;
}
const changeRequest = await getChangeRequest(ctx, spaceId, changeRequestId);
return (
<ToolbarLayout>
<Toolbar>
<Toolbar>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-xs text-light/8 dark:text-light/8">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="code-branch" className="size-4" />
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
</p>
<p className="text-xs text-light/8 dark:text-light/8">
Change request updated <DateRelative value={changeRequest.updatedAt} />
</p>
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<RefreshChangeRequestButton
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
<RefreshChangeRequestButton
ctx={ctx}
spaceId={spaceId}
changeRequestId={changeRequestId}
revisionId={changeRequest.revision}
updatedAt={new Date(changeRequest.updatedAt).getTime()}
/>
</ToolbarButtonGroups>
</Toolbar>
);
}
async function RevisionToolbar(props: { spaceId: string; revisionId: string }) {
const { spaceId, revisionId } = props;
const ctx = getGitBookContextFromHeaders(await headers());
const revision = await getRevision(spaceId, revisionId, {
const revision = await getRevision(ctx, spaceId, revisionId, {
metadata: true,
});
return (
<ToolbarLayout>
<Toolbar>
<Toolbar>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-xs text-light/8 dark:text-light/8">{revision.git.message}</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="code-commit" className="size-4" />
<Icon icon="arrow-up-right-from-square" className="size-4" />
</ToolbarButton>
<ToolbarBody>
<p>
Revision created <DateRelative value={revision.createdAt} />
</p>
{revision.git ? (
<p className="text-xs text-light/8 dark:text-light/8">
{revision.git.message}
</p>
) : null}
</ToolbarBody>
<ToolbarButtonGroups>
<ToolbarButton title="Open in application" href={revision.urls.app}>
<Icon icon="arrow-up-right-from-square" className="size-4" />
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
{revision.git?.url ? (
<ToolbarButton title="Open git commit" href={revision.git.url}>
<Icon
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
className="size-4"
/>
</ToolbarButton>
) : null}
</ToolbarButtonGroups>
</Toolbar>
</ToolbarLayout>
) : null}
</ToolbarButtonGroups>
</Toolbar>
);
}
@@ -1,8 +1,10 @@
'use client';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
import { GitBookContext } from '@/lib/gitbook-context';
import { tcls } from '@/lib/tailwind';
import { ToolbarButton } from './Toolbar';
@@ -14,6 +16,7 @@ const minInterval = 1000 * 30; // 5 minutes
* Button to refresh the page if the content has been updated.
*/
export function RefreshChangeRequestButton(props: {
ctx: GitBookContext;
spaceId: string;
changeRequestId: string;
revisionId: string;
+13 -38
View File
@@ -1,20 +1,14 @@
'use client';
import {
SiteAds,
SiteAdsStatus,
SiteInsightsAdPlacement,
SiteInsightsAd,
SiteInsightsTrademarkPlacement,
} from '@gitbook/api';
import { SiteAds, SiteAdsStatus } from '@gitbook/api';
import { headers } from 'next/headers';
import * as React from 'react';
import { t, useLanguage } from '@/intl/client';
import { getIpAndUserAgentFromHeaders, IpAndUserAgent } from '@/lib/gitbook-context';
import { ClassValue, tcls } from '@/lib/tailwind';
import { renderAd } from './renderAd';
import { useTrackEvent } from '../Insights';
import { Link } from '../primitives';
/**
* Zone ID provided by BuySellAds for the preview.
@@ -26,6 +20,7 @@ const PREVIEW_ZONE_ID = 'CVAIKKQM';
* https://docs.buysellads.com/ad-serving-api
*/
export function Ad({
ipAndUserAgent,
zoneId,
spaceId,
placement,
@@ -34,9 +29,10 @@ export function Ad({
style,
mode = 'auto',
}: {
ipAndUserAgent: IpAndUserAgent;
zoneId: string | null;
spaceId: string;
placement: SiteInsightsAdPlacement;
placement: string;
ignore: boolean;
style?: ClassValue;
siteAdsStatus?: SiteAds['status'];
@@ -44,20 +40,7 @@ export function Ad({
}) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
const [ad, setAd] = React.useState<
{ children: React.ReactNode; insightsAd: SiteInsightsAd | null } | undefined
>(undefined);
const trackEvent = useTrackEvent();
// Track display of the ad
React.useEffect(() => {
if (ad?.insightsAd) {
trackEvent({
type: 'ad_display',
ad: ad.insightsAd,
});
}
}, [ad]);
const [ad, setAd] = React.useState<React.ReactNode | undefined>(undefined);
// Observe the container visibility
React.useEffect(() => {
@@ -113,7 +96,7 @@ export function Ad({
(async () => {
const result = showPlaceholderAd
? await renderAd({ source: 'placeholder' })
? await renderAd({ source: 'placeholder', ipAndUserAgent })
: realZoneId
? await renderAd({
placement,
@@ -121,6 +104,7 @@ export function Ad({
zoneId: realZoneId,
mode,
source: 'live',
ipAndUserAgent,
})
: undefined;
@@ -136,13 +120,13 @@ export function Ad({
return () => {
cancelled = true;
};
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus]);
}, [visible, zoneId, ignore, placement, mode, siteAdsStatus, ipAndUserAgent]);
return (
<div ref={containerRef} className={tcls(style)} data-visual-test="removed">
{ad ? (
<>
{ad.children}
{ad}
<AdSponsoredLink spaceId={spaceId} />
</>
) : null}
@@ -153,7 +137,6 @@ export function Ad({
function AdSponsoredLink(props: { spaceId: string }) {
const { spaceId } = props;
const language = useLanguage();
const trackEvent = useTrackEvent();
const viaUrl = new URL('https://www.gitbook.com');
viaUrl.searchParams.set('utm_source', 'content');
@@ -171,17 +154,9 @@ function AdSponsoredLink(props: { spaceId: string }) {
'dark:text-light/5',
)}
>
<Link
target="_blank"
href={viaUrl.toString()}
className={tcls('hover:underline')}
insights={{
type: 'trademark_click',
placement: SiteInsightsTrademarkPlacement.Ad,
}}
>
<a target="_blank" href={viaUrl.toString()} className={tcls('hover:underline')}>
{t(language, 'sponsored_via_gitbook')}
</Link>
</a>
</p>
);
}
@@ -1,38 +1,23 @@
import { SiteInsightsAd } from '@gitbook/api';
import { headers } from 'next/headers';
import * as React from 'react';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdItem } from './types';
import { Link } from '../primitives';
/**
* Classic rendering for an ad.
*/
export async function AdClassicRendering({
ad,
insightsAd,
}: {
ad: AdItem;
insightsAd: SiteInsightsAd | null;
}) {
export async function AdClassicRendering({ ad }: { ad: AdItem }) {
const ctx = getGitBookContextFromHeaders(await headers());
const smallImgSrc =
'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null;
'smallImage' in ad ? getResizedImageURL(ctx, ad.smallImage, { width: 192, dpr: 2 }) : null;
const logoSrc =
'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null;
'logo' in ad ? getResizedImageURL(ctx, ad.logo, { width: 192 - 48, dpr: 2 }) : null;
return (
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
<a
className={tcls(
'flex',
'flex-col',
@@ -47,6 +32,8 @@ export async function AdClassicRendering({
'p-4',
)}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
{smallImgSrc && 'smallImage' in ad ? (
<div>
@@ -63,6 +50,6 @@ export async function AdClassicRendering({
<div className={tcls('flex', 'flex-col')}>
<div className={tcls('text-xs')}>{ad.description}</div>
</div>
</Link>
</a>
);
}
@@ -1,37 +1,22 @@
import { SiteInsightsAd } from '@gitbook/api';
import { headers } from 'next/headers';
import * as React from 'react';
import { hexToRgba } from '@/lib/colors';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { getResizedImageURL } from '@/lib/images';
import { tcls } from '@/lib/tailwind';
import { AdCover } from './types';
import { Link } from '../primitives';
/**
* Cover rendering for an ad.
*/
export async function AdCoverRendering({
ad,
insightsAd,
}: {
ad: AdCover;
insightsAd: SiteInsightsAd | null;
}) {
const largeImage = await getResizedImageURL(ad.largeImage, { width: 128, dpr: 2 });
export async function AdCoverRendering({ ad }: { ad: AdCover }) {
const ctx = getGitBookContextFromHeaders(await headers());
const largeImage = await getResizedImageURL(ctx, ad.largeImage, { width: 128, dpr: 2 });
return (
<Link
rel="sponsored noopener"
target="_blank"
insights={
insightsAd
? {
type: 'ad_click',
ad: insightsAd,
}
: undefined
}
<a
className={tcls(
'group/ad',
'relative',
@@ -51,6 +36,8 @@ export async function AdCoverRendering({
)}
style={{ backgroundColor: ad.backgroundColor, color: ad.textColor ?? '#ffffff' }}
href={ad.statlink}
rel="sponsored noopener"
target="_blank"
>
<div
className={tcls(
@@ -122,6 +109,6 @@ export async function AdCoverRendering({
backgroundColor: hexToRgba(ad.backgroundColor, 0.8),
}}
/>
</Link>
</a>
);
}
@@ -1,7 +1,6 @@
'use server';
import { SiteInsightsAd, SiteInsightsAdPlacement } from '@gitbook/api';
import { headers } from 'next/headers';
import { IpAndUserAgent } from '@/lib/gitbook-context';
import { AdClassicRendering } from './AdClassicRendering';
import { AdCoverRendering } from './AdCoverRendering';
@@ -21,9 +20,11 @@ interface FetchLiveAdOptions {
/** Mode to render the Ad */
mode: 'classic' | 'auto' | 'cover';
/** Name of the placement for the ad */
placement: SiteInsightsAdPlacement;
placement: string;
/** If true, we'll not track it as an impression */
ignore: boolean;
/** IP and User-Agent to use for the request */
ipAndUserAgent: IpAndUserAgent;
}
interface FetchPlaceholderAdOptions {
@@ -31,6 +32,8 @@ interface FetchPlaceholderAdOptions {
* Source of the ad (placeholder: static placeholder ad)
*/
source: 'placeholder';
/** IP and User-Agent to use for the request */
ipAndUserAgent: IpAndUserAgent;
}
/**
@@ -41,44 +44,33 @@ interface FetchPlaceholderAdOptions {
export async function renderAd(options: FetchAdOptions) {
const mode = options.source === 'live' ? options.mode : 'classic';
const result = options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd();
const result =
options.source === 'live' ? await fetchAd(options) : await getPlaceholderAd(options);
if (!result || !result.ad.description || !result.ad.statlink) {
return null;
}
const { ad } = result;
const insightsAd: SiteInsightsAd | null =
options.source === 'live'
? {
placement: options.placement,
zoneId: options.zoneId,
domain: 'company' in ad ? ad.company : '',
}
: null;
return {
children: (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} insightsAd={insightsAd} />
) : (
<AdCoverRendering ad={ad} insightsAd={insightsAd} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
),
insightsAd,
};
return (
<>
{mode === 'classic' || !('callToAction' in ad) ? (
<AdClassicRendering ad={ad} />
) : (
<AdCoverRendering ad={ad} />
)}
{ad.pixel ? <AdPixels rawPixel={ad.pixel} /> : null}
</>
);
}
async function fetchAd({
ipAndUserAgent,
zoneId,
placement,
ignore,
}: FetchLiveAdOptions): Promise<{ ad: AdItem; ip: string } | null> {
const { ip, userAgent } = await getUserAgentAndIp();
const { ip, userAgent } = ipAndUserAgent;
const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`);
url.searchParams.set('segment', `placement:${placement}`);
url.searchParams.set('v', 'true');
@@ -99,9 +91,7 @@ async function fetchAd({
return null;
}
async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
const { ip } = await getUserAgentAndIp();
function getPlaceholderAd(options: FetchPlaceholderAdOptions): { ad: AdItem; ip: string } {
return {
ad: {
active: '1',
@@ -128,20 +118,6 @@ async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> {
zoneid: '',
zonekey: '',
},
ip,
ip: options.ipAndUserAgent.ip,
};
}
async function getUserAgentAndIp() {
const headersSet = await headers();
const ip =
headersSet.get('x-gitbook-ipv4') ??
headersSet.get('x-gitbook-ip') ??
headersSet.get('cf-pseudo-ipv4') ??
headersSet.get('cf-connecting-ip') ??
headersSet.get('x-forwarded-for') ??
'';
const userAgent = headersSet.get('user-agent') ?? '';
return { ip, userAgent };
}
@@ -1,18 +1,23 @@
'use server';
import { getChangeRequest } from '@/lib/api';
import { GitBookContext } from '@/lib/gitbook-context';
/**
* Return true if a change-request has been updated.
*/
export async function hasContentBeenUpdated(props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
}) {
const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId);
if (!changeRequest) {
return false;
}
export async function hasContentBeenUpdated(
ctx: GitBookContext,
props: {
spaceId: string;
changeRequestId: string;
revisionId: string;
},
) {
const changeRequest = await getChangeRequest.revalidate(
ctx,
props.spaceId,
props.changeRequestId,
);
return changeRequest.revision !== props.revisionId;
}
@@ -1,6 +1,9 @@
'use client';
import React from 'react';
import { useEventCallback } from 'usehooks-ts';
import { GitBookContext } from '@/lib/gitbook-context';
import { hasContentBeenUpdated } from './server-actions';
@@ -8,17 +11,23 @@ import { hasContentBeenUpdated } from './server-actions';
* Return a callback to check if a change request has been updated and to refresh the page if it has.
*/
export function useCheckForContentUpdate(props: {
ctx: GitBookContext;
spaceId: string;
changeRequestId: string;
revisionId: string;
}) {
const { spaceId, changeRequestId, revisionId } = props;
const { ctx, spaceId, changeRequestId, revisionId } = props;
const getCtx = useEventCallback(() => ctx);
return React.useCallback(async () => {
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
const updated = await hasContentBeenUpdated(getCtx(), {
spaceId,
changeRequestId,
revisionId,
});
if (updated) {
window.location.reload();
}
}, [spaceId, changeRequestId, revisionId]);
}, [spaceId, changeRequestId, revisionId, getCtx]);
}
@@ -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,7 +1,9 @@
import { DocumentBlockContentRef, SiteInsightsLinkPosition } from '@gitbook/api';
import { DocumentBlockContentRef } from '@gitbook/api';
import { headers } from 'next/headers';
import { Card } from '@/components/primitives';
import { getSpaceCustomization, ignoreAPIError } from '@/lib/api';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { ResolvedContentRef } from '@/lib/references';
import { BlockProps } from './Block';
@@ -35,11 +37,8 @@ export async function BlockContentRef(props: BlockProps<DocumentBlockContentRef>
title={resolved.text}
style={style}
insights={{
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
target: block.data.ref,
position: 'content',
}}
/>
);
@@ -55,7 +54,9 @@ async function SpaceRefCard(
return null;
}
const { customization: spaceCustomization } = await getSpaceCustomization();
const ctx = getGitBookContextFromHeaders(await headers());
const { customization: spaceCustomization } = await getSpaceCustomization(ctx);
const customFavicon = spaceCustomization?.favicon;
const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined;
const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined;
@@ -23,7 +23,6 @@ export function Caption(
fit?: boolean;
wrapperStyle?: ClassValue;
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed | DocumentBlockFile;
withBorder?: boolean;
} & DocumentContextProps,
) {
const {
@@ -32,18 +31,22 @@ export function Caption(
block,
context,
fit = false,
withBorder = false,
wrapperStyle = [
'relative',
'overflow-hidden',
'rounded',
'straight-corners:rounded-none',
'after:block',
'after:absolute',
'after:-inset-[0]',
'after:border-dark/2',
'after:border',
'after:rounded',
'straight-corners:after:rounded-none',
'dark:after:border-light/1',
'dark:after:mix-blend-plus-lighter',
'after:pointer-events-none',
fit ? 'w-fit' : null,
withBorder
? 'rounded straight-corners:rounded-none after:border-dark/2 after:border after:rounded straight-corners:after:rounded-none dark:after:border-light/1 dark:after:mix-blend-plus-lighter after:pointer-events-none'
: null,
],
style,
} = props;
@@ -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);
@@ -101,12 +96,6 @@ function getLanguageForSyntax(syntax: string): BundledLanguage | null {
// Normalize the syntax to lowercase.
syntax = syntax.toLowerCase();
// Temporary disable highlighting for C/C++ code blocks
// @see https://github.com/shikijs/shiki/issues/893
if (syntax === 'cpp' || syntax === 'c') {
return null;
}
// Check if the syntax is a bundled language.
if (checkIsBundledLanguage(syntax)) {
return syntax;
@@ -121,25 +110,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 +124,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 +146,7 @@ export function plainHighlighting(
function matchTokenAndInlines(
eat: () => PositionedToken | null,
allInlines: RenderedInline[],
allInlines: InlineIndexed[],
): HighlightToken[] {
const initialToken = eat();
if (!initialToken) {
@@ -181,7 +154,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 +170,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 +185,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 +198,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 +209,8 @@ function matchTokenAndInlines(
});
result.push({
type: 'annotation',
body: inline.body,
type: 'inline',
inline: inline.inline,
children,
});
@@ -251,11 +224,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 +241,8 @@ function getPlainCodeBlock(code: DocumentBlockCode, inlines?: InlineIndexed[]):
function getPlainCodeBlockLine(
parent: DocumentBlockCodeLine | DocumentInlineAnnotation,
inlines: InlineIndexed[],
index: number,
inlines?: InlineIndexed[],
): string {
let content = '';
@@ -278,16 +251,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 +335,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 +349,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,
};
});
}
@@ -1,10 +1,11 @@
import * as gitbookAPI from '@gitbook/api';
import { headers } from 'next/headers';
import Script from 'next/script';
import ReactDOM from 'react-dom';
import { Card } from '@/components/primitives';
import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { getGitBookContextFromHeaders } from '@/lib/gitbook-context';
import { tcls } from '@/lib/tailwind';
import { BlockProps } from './Block';
@@ -12,30 +13,26 @@ import { Caption } from './Caption';
import { IntegrationBlock } from './Integration';
export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
const ctx = getGitBookContextFromHeaders(await headers());
const { block, context, ...otherProps } = props;
const nonce = await getContentSecurityPolicyNonce();
const nonce = ctx.nonce || undefined;
ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce });
const embed = await (context.content
? getEmbedByUrlInSpace(context.content.spaceId, block.data.url)
: getEmbedByUrl(block.data.url));
? getEmbedByUrlInSpace(ctx, context.content.spaceId, block.data.url)
: getEmbedByUrl(ctx, block.data.url));
return (
<Caption {...props} withBorder>
<Caption {...props}>
{embed.type === 'rich' ? (
<>
<div
dangerouslySetInnerHTML={{
__html: embed.html,
}}
data-visual-test="blackout"
/>
<Script
strategy="lazyOnload"
src="https://cdn.iframe.ly/embed.js"
nonce={nonce}
/>
<Script src="https://cdn.iframe.ly/embed.js" nonce={nonce} />
</>
) : embed.type === 'integration' ? (
<IntegrationBlock
@@ -1,4 +1,4 @@
import { DocumentBlockFile, SiteInsightsLinkPosition } from '@gitbook/api';
import { DocumentBlockFile } from '@gitbook/api';
import { getSimplifiedContentType } from '@/lib/files';
import { tcls } from '@/lib/tailwind';
@@ -21,25 +21,26 @@ export async function File(props: BlockProps<DocumentBlockFile>) {
const contentType = getSimplifiedContentType(file.contentType);
return (
<Caption {...props} withBorder>
<Caption {...props} wrapperStyle={[]}>
<Link
href={file.downloadURL}
download={file.name}
insights={{
type: 'link_click',
link: {
target: block.data.ref,
position: SiteInsightsLinkPosition.Content,
},
target: block.data.ref,
position: 'content',
}}
className={tcls(
'group/file',
'flex',
'flex-row',
'items-center',
'border',
'px-5',
'py-3',
'border-dark/3',
'rounded-lg',
'hover:text-primary-600',
'dark:border-light/3',
'dark:hover:text-primary-300',
)}
>
@@ -18,10 +18,7 @@ export function Heading(props: BlockProps<DocumentBlockHeading>) {
id = context.getId ? context.getId(id) : id;
return (
<Tag
id={id}
className={tcls(textStyle.textSize, 'heading', 'group', 'relative', 'grid', style)}
>
<Tag id={id} className={tcls(textStyle.textSize, 'group', 'relative', 'grid', style)}>
<div
className={tcls(
'grid',

Some files were not shown because too many files have changed in this diff Show More